Skip to content

Commit bc04da9

Browse files
committed
Implement agent output mode (NXF_AGENT)
Add AI agent-friendly output mode triggered by env vars: - NXF_AGENT=true - AGENT=true - CLAUDECODE=true When enabled, outputs minimal structured format: - [PIPELINE] name version | profile=X - [WORKDIR] path - [WARN] deduplicated warnings - [ERROR] with exit/cmd/stderr/workdir context - [SUCCESS|FAILED] summary Achieves 98-100% token savings for AI context windows. Signed-off-by: Edmund Miller <edmund.miller@seqera.io>
1 parent 9f24822 commit bc04da9

File tree

8 files changed

+285
-13
lines changed

8 files changed

+285
-13
lines changed

changelog.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
NEXTFLOW CHANGE-LOG
22
===================
3+
25.13.0-edge - UNRELEASED
4+
- Add AI agent-friendly output mode via NXF_AGENT/AGENT/CLAUDECODE env vars [NEW]
5+
36
25.12.0-edge - 19 Dec 2025
47
- Add `listDirectory()` to Path type and deprecate `listFiles()` (#6581) [56f0f007]
58
- Add default maxSpotAttempts for fusion snapshots in Google Batch (#6652) [458ef97a]

modules/nextflow/src/main/groovy/nextflow/Session.groovy

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import nextflow.script.ScriptRunner
6969
import nextflow.script.WorkflowMetadata
7070
import nextflow.script.dsl.ProcessConfigBuilder
7171
import nextflow.spack.SpackConfig
72+
import nextflow.trace.AgentLogObserver
7273
import nextflow.trace.AnsiLogObserver
7374
import nextflow.trace.TraceObserver
7475
import nextflow.trace.TraceObserverFactory
@@ -312,10 +313,14 @@ class Session implements ISession {
312313

313314
boolean ansiLog
314315

316+
boolean agentLog
317+
315318
boolean disableJobsCancellation
316319

317320
AnsiLogObserver ansiLogObserver
318321

322+
AgentLogObserver agentLogObserver
323+
319324
FilePorter getFilePorter() { filePorter }
320325

321326
/**
@@ -499,6 +504,12 @@ class Session implements ISession {
499504
final result = new ArrayList<TraceObserverV2>(10)
500505
this.statsObserver = new WorkflowStatsObserver(this)
501506
result.add(statsObserver)
507+
// Add AgentLogObserver when agent mode is enabled
508+
if( agentLog ) {
509+
this.agentLogObserver = new AgentLogObserver()
510+
agentLogObserver.setStatsObserver(statsObserver)
511+
result.add(agentLogObserver)
512+
}
502513
for( TraceObserverFactoryV2 f : Plugins.getExtensions(TraceObserverFactoryV2) ) {
503514
log.debug "Observer factory (v2): ${f.class.simpleName}"
504515
result.addAll(f.create(this))
@@ -834,6 +845,7 @@ class Session implements ISession {
834845
notifyError(null)
835846
// force termination
836847
ansiLogObserver?.forceTermination()
848+
agentLogObserver?.forceTermination()
837849
executorFactory?.signalExecutors()
838850
processesBarrier.forceTermination()
839851
monitorsBarrier.forceTermination()

modules/nextflow/src/main/groovy/nextflow/cli/CmdRun.groovy

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,8 @@ class CmdRun extends CmdBase implements HubOptions {
404404
runner.setPreview(this.preview, previewAction)
405405
runner.session.profile = profile
406406
runner.session.commandLine = launcher.cliString
407-
runner.session.ansiLog = launcher.options.ansiLog
407+
runner.session.ansiLog = launcher.options.ansiLog && !SysEnv.isAgentMode()
408+
runner.session.agentLog = SysEnv.isAgentMode()
408409
runner.session.debug = launcher.options.remoteDebug
409410
runner.session.disableJobsCancellation = getDisableJobsCancellation()
410411

@@ -435,6 +436,11 @@ class CmdRun extends CmdBase implements HubOptions {
435436
}
436437

437438
protected void printBanner() {
439+
// Suppress banner in agent mode for minimal output
440+
if( SysEnv.isAgentMode() ) {
441+
return
442+
}
443+
438444
if( launcher.options.ansiLog ){
439445
// Plain header for verbose log
440446
log.debug "N E X T F L O W ~ version ${BuildInfo.version}"
@@ -499,6 +505,11 @@ class CmdRun extends CmdBase implements HubOptions {
499505
}
500506

501507
protected void printLaunchInfo(String repo, String head, String revision) {
508+
// Agent mode output is handled by AgentLogObserver
509+
if( SysEnv.isAgentMode() ) {
510+
return
511+
}
512+
502513
if( launcher.options.ansiLog ){
503514
log.debug "${head} [$runName] - revision: ${revision}"
504515

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/*
2+
* Copyright 2013-2024, Seqera Labs
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package nextflow.trace
18+
19+
import java.util.concurrent.ConcurrentHashMap
20+
21+
import groovy.util.logging.Slf4j
22+
import nextflow.Session
23+
import nextflow.processor.TaskRun
24+
import nextflow.trace.event.TaskEvent
25+
26+
/**
27+
* AI agent-friendly log observer that outputs minimal, structured information
28+
* optimized for AI context windows.
29+
*
30+
* Activated via environment variables: NXF_AGENT=1, AGENT=1, or CLAUDECODE=1
31+
*
32+
* Output format:
33+
* - [PIPELINE] name version | profile=X
34+
* - [WORKDIR] /path/to/work
35+
* - [WARN] warning message (deduplicated)
36+
* - [ERROR] PROCESS_NAME (sample_id) with exit/cmd/stderr/workdir
37+
* - [SUCCESS|FAILED] completed=N failed=N cached=N
38+
*
39+
* @author Edmund Miller <edmund.miller@utdallas.edu>
40+
*/
41+
@Slf4j
42+
class AgentLogObserver implements TraceObserverV2 {
43+
44+
private Session session
45+
private WorkflowStatsObserver statsObserver
46+
private final Set<String> seenWarnings = ConcurrentHashMap.newKeySet()
47+
private volatile boolean started = false
48+
private volatile boolean completed = false
49+
50+
/**
51+
* Set the workflow stats observer for retrieving task statistics
52+
*/
53+
void setStatsObserver(WorkflowStatsObserver observer) {
54+
this.statsObserver = observer
55+
}
56+
57+
/**
58+
* Print a line to standard output (agent format)
59+
*/
60+
protected void println(String line) {
61+
System.out.println(line)
62+
}
63+
64+
// -- TraceObserverV2 lifecycle methods --
65+
66+
@Override
67+
void onFlowCreate(Session session) {
68+
this.session = session
69+
}
70+
71+
@Override
72+
void onFlowBegin() {
73+
if( started )
74+
return
75+
started = true
76+
77+
// Print pipeline info
78+
def manifest = session.manifest
79+
def pipelineName = manifest?.name ?: session.scriptName ?: 'unknown'
80+
def version = manifest?.version ?: ''
81+
def profile = session.profile ?: 'standard'
82+
83+
def info = "[PIPELINE] ${pipelineName}"
84+
if( version )
85+
info += " ${version}"
86+
info += " | profile=${profile}"
87+
println(info)
88+
89+
// Print work directory
90+
def workDir = session.workDir?.toUriString() ?: session.workDir?.toString()
91+
if( workDir )
92+
println("[WORKDIR] ${workDir}")
93+
}
94+
95+
@Override
96+
void onFlowComplete() {
97+
if( completed )
98+
return
99+
completed = true
100+
printSummary()
101+
}
102+
103+
@Override
104+
void onFlowError(TaskEvent event) {
105+
// Error is already reported by onTaskComplete for failed tasks
106+
}
107+
108+
@Override
109+
void onTaskSubmit(TaskEvent event) {
110+
// Not reported in agent mode
111+
}
112+
113+
@Override
114+
void onTaskComplete(TaskEvent event) {
115+
def handler = event.handler
116+
def task = handler?.task
117+
if( task?.isFailed() ) {
118+
printTaskError(task)
119+
}
120+
}
121+
122+
@Override
123+
void onTaskCached(TaskEvent event) {
124+
// Not reported in agent mode
125+
}
126+
127+
/**
128+
* Append a warning message (deduplicated)
129+
*/
130+
void appendWarning(String message) {
131+
if( message == null )
132+
return
133+
// Normalize and deduplicate
134+
def normalized = message.trim().replaceAll(/\s+/, ' ')
135+
if( seenWarnings.add(normalized) ) {
136+
println("[WARN] ${normalized}")
137+
}
138+
}
139+
140+
/**
141+
* Append an error message
142+
*/
143+
void appendError(String message) {
144+
if( message )
145+
println("[ERROR] ${message}")
146+
}
147+
148+
/**
149+
* Append info (suppressed in agent mode except for critical messages)
150+
*/
151+
void appendInfo(String message) {
152+
// Suppress most info messages in agent mode
153+
// Only pass through if it contains critical keywords
154+
if( message && (message.contains('ERROR') || message.contains('WARN')) ) {
155+
println(message)
156+
}
157+
}
158+
159+
/**
160+
* Print task error with full diagnostic context
161+
*/
162+
protected void printTaskError(TaskRun task) {
163+
def name = task.getName()
164+
println("[ERROR] ${name}")
165+
166+
// Exit status
167+
def exitStatus = task.getExitStatus()
168+
if( exitStatus != null && exitStatus != Integer.MAX_VALUE ) {
169+
println("exit: ${exitStatus}")
170+
}
171+
172+
// Command/script (first line or truncated)
173+
def script = task.getScript()?.toString()?.trim()
174+
if( script ) {
175+
// Truncate long commands
176+
def cmd = script.length() > 200 ? script.substring(0, 200) + '...' : script
177+
cmd = cmd.replaceAll(/\n/, ' ').replaceAll(/\s+/, ' ')
178+
println("cmd: ${cmd}")
179+
}
180+
181+
// Stderr
182+
def stderr = task.getStderr()
183+
if( stderr ) {
184+
def lines = stderr.readLines()
185+
if( lines.size() > 10 ) {
186+
lines = lines[-10..-1]
187+
}
188+
println("stderr: ${lines.join(' | ')}")
189+
}
190+
191+
// Stdout (only if relevant)
192+
def stdout = task.getStdout()
193+
if( stdout && !stderr ) {
194+
def lines = stdout.readLines()
195+
if( lines.size() > 5 ) {
196+
lines = lines[-5..-1]
197+
}
198+
println("stdout: ${lines.join(' | ')}")
199+
}
200+
201+
// Work directory
202+
def workDir = task.getWorkDir()
203+
if( workDir ) {
204+
println("workdir: ${workDir.toUriString()}")
205+
}
206+
}
207+
208+
/**
209+
* Print final summary line
210+
*/
211+
protected void printSummary() {
212+
def stats = statsObserver?.getStats()
213+
def succeeded = stats?.succeededCount ?: 0
214+
def failed = stats?.failedCount ?: 0
215+
def cached = stats?.cachedCount ?: 0
216+
def completed = succeeded + failed
217+
218+
def status = failed > 0 ? 'FAILED' : 'SUCCESS'
219+
println("\n[${status}] completed=${completed} failed=${failed} cached=${cached}")
220+
}
221+
222+
/**
223+
* Force termination - called on abort
224+
*/
225+
void forceTermination() {
226+
if( !completed ) {
227+
completed = true
228+
printSummary()
229+
}
230+
}
231+
}

modules/nextflow/src/main/groovy/nextflow/util/LoggerHelper.groovy

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -703,7 +703,7 @@ class LoggerHelper {
703703

704704
/**
705705
* Capture logging events and forward them to. This is only used when
706-
* ANSI interactive logging is enabled
706+
* ANSI interactive logging or agent logging is enabled
707707
*/
708708
static private class CaptureAppender extends AppenderBase<ILoggingEvent> {
709709

@@ -713,6 +713,19 @@ class LoggerHelper {
713713

714714
try {
715715
final message = fmtEvent(event, session, false)
716+
717+
// Forward to AgentLogObserver when agent mode is enabled
718+
final agentRenderer = session?.agentLogObserver
719+
if( agentRenderer ) {
720+
if( event.level==Level.ERROR )
721+
agentRenderer.appendError(message)
722+
else if( event.level==Level.WARN )
723+
agentRenderer.appendWarning(message)
724+
else
725+
agentRenderer.appendInfo(message)
726+
return
727+
}
728+
716729
final renderer = session?.ansiLogObserver
717730
if( !renderer || !renderer.started || renderer.stopped )
718731
System.out.println(message)

0 commit comments

Comments
 (0)