mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
* fix(core): consolidate AbortController handling to stop listener leaks in long sessions
Users hit `MaxListenersExceededWarning: 1509 abort listeners added to
[AbortSignal]` in long interactive sessions. The agent runtime nests
parent→child controllers (masterAbortController → per-message round →
per-API-call round → tool execution) and each layer registered its own
`addEventListener('abort', ...)` on the parent without `{once:true}` or
reverse cleanup, so listeners accumulated on long-lived parents across
hundreds of model turns.
Add `utils/abortController.ts` with three helpers:
- `createAbortController(maxListeners = 50)` — factory that pre-caps the
signal so the warning never fires on per-request signals.
- `createChildAbortController(parent)` — WeakRef-based parent→child
propagation with `{once:true}` on the parent listener AND a reverse-cleanup
listener on the child that detaches the parent listener when the child
aborts. This is the key mechanism — short-lived children stop accumulating
dead listeners on long-lived parents.
- `combineAbortSignals(signals, {timeoutMs})` — N-way combiner that replaces
the existing one-input `combinedAbortSignal.ts` (kept as a `@deprecated`
shim so `httpHookRunner.ts` doesn't churn).
Migrate every production `new AbortController()` in `packages/core/src` (24
sites) to the helper. Wrap `_runReasoningLoopInner` per-iteration body and
`AgentHeadless.execute` in `try/finally` so the round controller is aborted
(triggering reverse cleanup) even when the model stream or tool execution
throws. Add `{once:true}` to the manual abort listeners in `hookRunner`,
`functionHookRunner`, and `message-bus` that were missing it. Remove the
`raiseAbortListenerCap` band-aid from `openaiContentGenerator/pipeline.ts` —
no longer needed now that the per-round signal carries `maxListeners=50`.
Add `cli/utils/warningHandler.ts` as a belt-and-suspenders: hides
`MaxListenersExceededWarning.*AbortSignal` from end users in production
(any shape Node ≥20 emits), keeps it visible under `DEBUG`/`QWEN_DEBUG`/
`NODE_ENV=development`. Uses `process.on('warning', ...)` without
`removeAllListeners` so third-party warning subscribers stay intact.
Direct reproducer in `docs/verification/abort-controller-refactor/` proves
the old pattern accumulates 2000 listeners over 2000 rounds while the new
pattern stays at 0.
* fix(core): address PR #4366 review feedback
Four issues from the Copilot review:
1. combineAbortSignals — add a per-iteration `aborted` check inside the
for-loop so we short-circuit if an input signal flips aborted between
the initial scan and listener registration. In single-threaded JS this
can't actually interleave, but the defensive check makes correctness
obvious and protects against signals whose `aborted` getter has side
effects. New test exercises the path via a Proxy that flips after the
initial scan.
2. warningHandler docstring — was stale: said "AbortSignal / EventTarget"
while the regex was tightened to AbortSignal-only in the previous review.
3. README.md — replace personal absolute path with `$WT` placeholder so
the verification recipe is shareable.
4. README.md — replace the markdown table with per-scenario headed
sections. Prettier had interpreted an inline `ps -ef | grep sleep`
pipe character as a column separator, breaking the table rendering on
GitHub. Per-section format is also easier to scan and edit.
* test(core): fix abortController race-defense test to actually hit the loop check
The previous version set the Proxy's `aborted` to true before calling
combineAbortSignals, so the initial `find` scan caught it and we took the
fast path — not the per-iteration check the test was meant to validate.
Switch to an access counter so `aborted` is false on the first read (during
`find`) and true on subsequent reads (inside the loop). This forces the
loop to enter, then catches the flip via the defensive per-iteration check
before any listener is attached to the next input.
Verified the test fails if the per-iteration check is removed.
* fix(lint): include docs/**/*.mjs in the script ESLint block so the AbortController repro passes lint
CI Lint flagged 11 no-undef errors in
docs/verification/abort-controller-refactor/listener-accumulation-repro.mjs
(AbortController, console, process) because the project's flat config
only declared Node globals for ./scripts/**/*.mjs.
The reviewer's suggestion (`/* eslint-env node */`) doesn't work under
ESLint 9 flat config — env directives are deprecated there. The proper
fix is to extend the existing script-globals block to also cover the
verification repro script under docs/.
* fix(core,cli): address PR #4366 critical review findings
Two real bugs the reviewer caught and I confirmed locally:
1. warningHandler.ts didn't actually suppress anything. Adding a
`process.on('warning')` listener does NOT prevent Node's default
onWarning printer from writing to stderr — the default is just an
ordinary listener registered in `lib/internal/process/warning.js`.
My previous code therefore:
- failed to suppress targeted AbortSignal warnings (they still hit
stderr via the default printer)
- produced a SECOND copy of every non-suppressed warning (default
printer + my handler's own stderr.write)
The unit tests missed it because they synthesised a fake warning and
called `process.listeners('warning')` directly rather than going
through `process.emitWarning`.
Fix: snapshot the existing `'warning'` listeners (which include the
default printer and any third-party telemetry hooks) BEFORE replacing
them. Install ours as the sole listener. For non-suppressed warnings
fan out to the captured set so the default printer + telemetry still
fire; for suppressed warnings stop here. Tests now use
`process.emit('warning', ...)` to drive the real listener chain, plus
a spawned-child integration test that asserts the real stderr from
`process.emitWarning` is empty for AbortSignal warnings and still
contains DeprecationWarning text.
2. abortController.createChildAbortController kept a WeakRef to the
child controller. A natural usage pattern — pass `child.signal` into
an async API and drop the controller object — could let the
controller be GC'd while the signal is still in use, after which
`parent.abort()` would no longer propagate. Reproduced with
`node --expose-gc`.
Fix: hold the child strongly via the parent's listener closure. The
reverse-cleanup listener still removes the closure when child aborts
(closure releases child → GC-eligible), and the parent's `{once:true}`
listener self-removes when parent fires (same effect). Net listener
accounting on long-lived parents is unchanged; the only difference is
the controller now stays alive long enough for propagation to reach
downstream consumers that hold only the signal. Tests updated: drop
the old `--expose-gc`-dependent assertion that abandoned children
GC immediately (that was a property of the OLD contract); add a
signal-only-retention test that verifies propagation under the new
contract without needing GC at all.
Verified: 32 helper/warning tests pass (incl. spawned-child stderr
integration); 363 affected caller tests pass; typecheck + prettier +
eslint clean for the touched files.
* fix(core,cli): address PR #4366 review — fix combineAbortSignals orphan listeners + runtime DEBUG toggle
Two real bugs the reviewer caught:
1. combineAbortSignals registered its cleanup listener on
controller.signal AFTER the for-loop. Node does NOT fire 'abort'
listeners added to an already-aborted signal, so when the
per-iteration defensive check aborted the controller mid-loop, the
cleanup never ran — orphaning every input-signal listener registered
before the break, and leaving the (also-registered-after-the-break)
setTimeout uncleared.
Fix: skip timeout scheduling when controller.signal.aborted is
already true post-loop, and when it's true call cleanup()
synchronously instead of registering a doomed listener. Existing
test for the mid-iteration path now also asserts that the
pre-break input signal (a) has zero abort listeners — that's the
assertion that catches the orphan bug. New test for the
already-aborted-input + timeoutMs combination confirms the timer
isn't scheduled (would otherwise overwrite the abort reason).
2. warningHandler captured isDebugMode() in a closure at init time, so
toggling DEBUG / QWEN_DEBUG at runtime (e.g. via a /debug slash
command) didn't update suppression behavior. Moved the check inside
the handler — warnings are rare so the per-emit env-lookup cost is
negligible. New test asserts a mid-stream DEBUG=1 flip starts
forwarding suppressed warnings to the prior-listener chain.
* test(core): strengthen the timeout-guard test in combineAbortSignals to actually exercise the new !aborted check
Reviewer correctly pointed out that the previous version of this test
took the pre-loop fast path (since `a.abort('pre')` ran before
`combineAbortSignals`), so it never reached the in-loop guard at
abortController.ts:138.
Switched to the Proxy `aborted`-getter pattern from the sibling
mid-iteration test (so the loop genuinely re-checks `aborted` and
short-circuits inside the for-loop), and added a `setTimeout` spy that
asserts the timer was never scheduled — this is the only observable
difference from "scheduled then immediately cleared by synchronous
cleanup()", which is what the timer-advance assertion alone couldn't
distinguish.
Verified by mutation testing: removing the guard makes the new test
fail; restoring it makes it pass. Refs PR #4366.
* test(core): cover timeout-triggered cleanup of input-signal listeners in combineAbortSignals
Reviewer noted the timeout path only had an empty-input test, leaving
the leak-sensitive case uncovered: when timeoutMs fires with a
long-lived source signal in the input list, do the input-side
listeners get released? They do (the timeout callback aborts the
combined controller, which fires the auto-cleanup listener registered
on its signal, which calls the per-input removeEventListener), but
that path wasn't tested.
Adds a test that snapshots the source listener count before, asserts
it increased by 1 after combineAbortSignals returns, advances fake
timers past timeoutMs, and asserts the count returns to baseline.
Refs PR #4366.
* fix(test): use pathToFileURL for the warning-handler e2e import on Windows
CI failure on windows-latest:
AssertionError: expected '\r\nnode:internal/modules/run_main:12…'
to match /DeprecationWarning.*Plain deprecation/
Error [ERR_UNSUPPORTED_ESM_URL_SCHEME]: Only URLs with a scheme in:
file, data, and node are supported by the default ESM loader. On
Windows, absolute paths must be valid file:// URLs. Received protocol 'd:'
The e2e test wrote a child script with an `import "<helperPath>"` where
helperPath was a raw Windows absolute path (`D:\a\qwen-code\...`). Node's
ESM loader parses that as a URL on Windows and rejects the `D:` "scheme".
Converted the helper path to a `file://` URL via `pathToFileURL`. macOS
test still passes; the Windows-specific schemes-must-be-URL behavior is
now honored. Refs PR #4366.
* fix(core,cli): address PR #4366 review batch — onAbort leak, migrate missed sites, tighten tests
Adopted 6 of the 7 review threads (skipping the debug-logging suggestion).
1. processFunctionCalls onAbort leak (CRITICAL): the new
`finally { roundAbortController.abort(); }` in _runReasoningLoopInner
would fire the `onAbort` handler in `processFunctionCalls` if
scheduler.schedule or batchDone threw (the explicit
removeEventListener at the old happy-path exit would be skipped),
emitting spurious "Tool call cancelled by user abort." TOOL_RESULT
events for every un-emitted callId — corrupting the transcript and
misleading the model on the next round. Fixed by wrapping schedule
+ batchDone in their own try/finally so removeEventListener always
runs before the outer finally's abort.
2. Migrate 3 new-from-main `new AbortController()` sites that this
PR's audit missed (they came in via the merge from main):
- goals/goalHook.ts (2 sites: judgeController, fallback signal) —
consistency
- hooks/promptHookRunner.ts (1 site: internalAbortController) —
real leak (manual addEventListener without {once:true} or
cleanup, exactly the pattern this PR exists to fix). Switched to
createChildAbortController + finally `internalAbortController.abort()`
for reverse cleanup on the success path.
3. Repro script (`listener-accumulation-repro.mjs`): inlined helper
diverged from production — used WeakRef on child, while production
was changed to strong-ref earlier in this PR. Updated the inlined
copy to match production exactly, with a comment noting the
intentional WeakRef-on-parent-only pattern.
4. warningHandler.ts: documented the snapshot-and-replace trade-offs
in the JSDoc (late-added listeners bypass our filter; late
`removeListener` calls have no effect on our fan-out). Tried the
re-snapshot-per-warning approach the reviewer suggested but it
doesn't work — `removeAllListeners('warning')` permanently removes
the snapshot from Node's tracking, so a `process.listeners('warning')`
filter at fan-out time always returns empty for prior listeners.
The current design is the right trade-off; documentation is the
correct fix.
5. abortController.test.ts: added three coverage gaps the reviewer
identified —
- createChildAbortController forwards custom maxListeners
- manual cleanup() before scheduled timeout fires cancels it
- timeoutMs <= 0 is treated as "no timeout"
6. Migrated `httpHookRunner.ts:202` (the lone caller of the deprecated
`createCombinedAbortSignal`) to `combineAbortSignals` directly,
then deleted `combinedAbortSignal.ts` + its test. All semantics
covered by `combineAbortSignals` tests in abortController.test.ts.
Refreshed `migration-completeness.txt` (now empty — clean grep).
Tests: 194 pass across abortController/warningHandler/agent-runtime/
followup/hooks/goal/promptHook suites. Typecheck + prettier clean.
* docs(verification): commit the headless-scenario scripts referenced by the PR body
The PR body's "End-to-end scenarios I drove locally" section points at
docs/verification/abort-controller-refactor/scripts/02-lite.sh and 06-headless-sigint.sh.
These are the actual reproducible commands behind the EXIT codes /
warning counts reported there — checking them in so anyone can replay
without copy-pasting from the PR description.
Refs PR #4366.
* docs(verification): sync automated-results with current state
Two doc fixes the reviewer flagged:
- migration-completeness.txt was a 0-byte file with a confusing
cross-reference. Populated with the actual grep command + its
"(no output)" result so the empty-output state is explicit.
- automated-results.md still referenced combinedAbortSignal.test.ts (8
tests, @deprecated shim) — both files were deleted in 94e8c5812 when
httpHookRunner.ts migrated to combineAbortSignals directly. Replaced
the line with a reference to httpHookRunner.test.ts. Also updated
the test counts to reflect current state (26 abortController, 13
warningHandler — both grew with the review cycle) and removed the
stale combinedAbortSignal.ts entry from the prettier-check command.
Refs PR #4366.
* test(core): pin two abort-cascade behaviors PR #4366 introduced
Adopting 2 of 3 new review threads (the third — automated-results.md
drift — was already fixed in 5aa7110e4).
1. packages/core/src/agents/arena/ArenaManager.test.ts: pin the
master→agent abort cascade introduced by switching per-agent
controllers to `createChildAbortController(this.masterAbortController)`.
New test spawns ≥2 agents, calls `manager.cancel()`, and asserts every
`agentState.abortController.signal.aborted === true`. Existing cancel
test only checked backend + status; if a future refactor re-introduced
independent controllers, the cascade would silently regress.
2. packages/core/src/followup/speculation.test.ts: cover the
`startSpeculation` abort wiring introduced when the manual
addEventListener + .finally removeEventListener pattern got replaced
by createChildAbortController + .finally abort(). Three tests:
- parent abort propagates to state.abortController (lifetime contract)
- parent-already-aborted fast path returns aborted state
- parent-signal listener count returns to baseline after the fire-and-
forget loop settles (reverse-cleanup proof)
Mocked `runWithForkedChatModel` and `OverlayFs` so the background
loop is a no-op — these tests only assert the synchronous wiring,
not the loop's content.
* fix(test): speculation.test.ts TS errors + sync verification doc counts
Two real CI blockers in the just-added speculation tests (TS2554 and
TS2339) plus stale doc counts the reviewer flagged.
1. saveCacheSafeParams takes 3 positional args (generationConfig,
history, model), not a single object. Compile error on every
platform. Fixed by switching to the correct shape; also moved
getEventListeners to a static `import` at the top of the file
(dynamic `await import('node:events')` exposes EventEmitter's
static method via the namespace type rather than as a direct
property, so destructuring fails type-check).
2. docs/verification/abort-controller-refactor/README.md still claimed
"18 + 1 GC" tests for abortController and "9" for warningHandler;
actual current counts are 26 and 13. Also dropped the stale
combinedAbortSignal reference and added a note about the new
ArenaManager cascade + startSpeculation wiring pin tests.
Refreshed smoke-boot.log against current built bin (still 0.15.11,
which is what package.json reports on this branch).
Refs PR #4366.
* refactor(core): narrow PR #4366 scope per yiliang's review — revert independent-controller migrations
Adopting @yiliang114's review feedback (#4366 review comment, 2026-05-22):
keep only the migrations that fix the real leak path (the agent-runtime
parent→child chain that accumulates listeners on a long-lived parent
signal in long sessions) and revert the consistency-only migrations on
independent short-lived controllers.
Issue #4423 confirms the user-visible bug is the nested-chain
accumulation — the reverted sites do not contribute to that bug.
Migrations KEPT:
- agents/runtime/agent-interactive.ts (master + per-message round)
- agents/runtime/agent-core.ts (per-iteration + wait + processFunctionCalls)
- agents/runtime/agent-headless.ts (external → execution)
- hooks/promptHookRunner.ts (real cleanup leak: addEventListener without
{once:true}, never removed)
- hooks/httpHookRunner.ts → combineAbortSignals direct (shim deleted)
- hookRunner.ts / functionHookRunner.ts / message-bus.ts: {once:true} only
- openaiContentGenerator/pipeline.ts band-aid removal (per-request signals
are children of the per-round controller, which carries maxListeners=50)
- warningHandler.ts belt-and-suspenders
Migrations REVERTED (independent short-lived controllers; restored to
`new AbortController()` + their original cleanup patterns):
- agents/arena/ArenaManager.ts (master + per-agent)
- agents/background-agent-resume.ts (3 sites)
- core/client.ts (recall — restored manual addEventListener + finally
removeEventListener pattern from main)
- followup/speculation.ts (restored parentAbortHandler + finally
removeEventListener)
- goals/goalHook.ts (judgeController + fallback signal)
- memory/manager.ts (dream controller)
- services/chatCompressionService.ts (fallback signal)
- services/chatRecordingService.ts (autoTitle controller)
- tools/agent/agent.ts (fg + bg subagent controllers — restored manual
onParentAbort + finally removeEventListener)
- tools/monitor.ts (entryAc)
- tools/shell.ts (promote + 3 entryAc)
- utils/fetch.ts (fetchWithTimeout)
Tests removed alongside the reverts:
- ArenaManager.test.ts "cancels cascades..." — the cascade itself was an
intentional behavioral improvement that's now reverted, so the
pin-test belongs with it
- speculation.test.ts "startSpeculation — abort-controller wiring" block
(3 tests) — they tested helper-wired behavior we reverted
Verification docs updated to reflect the narrower scope.
Net change: 19 raw `new AbortController()` remain (intentional, per
migration-completeness.txt rationale); previously was 0.
Refs PR #4366, issue #4423.
464 lines
15 KiB
TypeScript
464 lines
15 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* @fileoverview AgentInteractive — persistent interactive agent.
|
|
*
|
|
* Composes AgentCore with on-demand message processing. Builds conversation
|
|
* state (messages, pending approvals, live outputs) that the UI reads.
|
|
*/
|
|
|
|
import {
|
|
createAbortController,
|
|
createChildAbortController,
|
|
} from '../../utils/abortController.js';
|
|
import { createDebugLogger } from '../../utils/debugLogger.js';
|
|
import { type AgentEventEmitter, AgentEventType } from './agent-events.js';
|
|
import type {
|
|
AgentApprovalRequestEvent,
|
|
AgentToolOutputUpdateEvent,
|
|
AgentToolResultEvent,
|
|
} from './agent-events.js';
|
|
import type { AgentStatsSummary } from './agent-statistics.js';
|
|
import type { AgentCore } from './agent-core.js';
|
|
import type { ContextState } from './agent-headless.js';
|
|
import type { GeminiChat } from '../../core/geminiChat.js';
|
|
import type { FunctionDeclaration } from '@google/genai';
|
|
import {
|
|
ToolConfirmationOutcome,
|
|
type ToolCallConfirmationDetails,
|
|
type ToolResultDisplay,
|
|
} from '../../tools/tools.js';
|
|
import { AsyncMessageQueue } from '../../utils/asyncMessageQueue.js';
|
|
import {
|
|
AgentTerminateMode,
|
|
AgentStatus,
|
|
isTerminalStatus,
|
|
type AgentInteractiveConfig,
|
|
type AgentMessage,
|
|
} from './agent-types.js';
|
|
|
|
const debugLogger = createDebugLogger('AGENT_INTERACTIVE');
|
|
|
|
/**
|
|
* AgentInteractive — persistent interactive agent that processes
|
|
* messages on demand.
|
|
*
|
|
* Three-level cancellation:
|
|
* - `cancelCurrentRound()` — abort the current reasoning loop only
|
|
* - `shutdown()` — graceful: stop accepting messages, wait for cycle
|
|
* - `abort()` — immediate: master abort, set cancelled
|
|
*/
|
|
export class AgentInteractive {
|
|
readonly config: AgentInteractiveConfig;
|
|
private readonly core: AgentCore;
|
|
private readonly queue = new AsyncMessageQueue<string>();
|
|
|
|
private status: AgentStatus = AgentStatus.INITIALIZING;
|
|
private error: string | undefined;
|
|
private lastRoundError: string | undefined;
|
|
private executionPromise: Promise<void> | undefined;
|
|
private masterAbortController = createAbortController();
|
|
private roundAbortController: AbortController | undefined;
|
|
private chat: GeminiChat | undefined;
|
|
private toolsList: FunctionDeclaration[] = [];
|
|
private processing = false;
|
|
private roundCancelledByUser = false;
|
|
|
|
// Wall-clock timestamp when each currently-executing tool transitioned into
|
|
// the scheduler's `executing` state. Keyed by callId. First TOOL_OUTPUT_UPDATE
|
|
// carrying executionStartTime wins; later events that re-carry it are ignored
|
|
// so the timer is stable. Lives on InteractiveAgent (not AgentCore) because
|
|
// it's only consumed by the interactive UI's elapsed-time indicator.
|
|
private readonly executionStartTimes = new Map<string, number>();
|
|
|
|
constructor(config: AgentInteractiveConfig, core: AgentCore) {
|
|
this.config = config;
|
|
this.core = core;
|
|
this.setupEventListeners();
|
|
}
|
|
|
|
// ─── Lifecycle ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* Start the agent. Initializes the chat session, then kicks off
|
|
* processing if an initialTask is configured.
|
|
*/
|
|
async start(context: ContextState): Promise<void> {
|
|
this.setStatus(AgentStatus.INITIALIZING);
|
|
|
|
this.chat = await this.core.createChat(context, {
|
|
interactive: true,
|
|
extraHistory: this.config.chatHistory,
|
|
});
|
|
if (!this.chat) {
|
|
this.error = 'Failed to create chat session';
|
|
this.setStatus(AgentStatus.FAILED);
|
|
return;
|
|
}
|
|
|
|
this.toolsList = await this.core.prepareTools();
|
|
this.core.stats.start(Date.now());
|
|
|
|
if (this.config.chatHistory?.length) {
|
|
this.addMessage(
|
|
'info',
|
|
`History context from parent session included (${this.config.chatHistory.length} messages)`,
|
|
);
|
|
}
|
|
|
|
if (this.config.initialTask) {
|
|
this.queue.enqueue(this.config.initialTask);
|
|
this.executionPromise = this.runLoop();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run loop: process all pending messages, then settle status.
|
|
* Exits when the queue is empty or the agent is aborted.
|
|
*/
|
|
private async runLoop(): Promise<void> {
|
|
this.processing = true;
|
|
try {
|
|
let message = this.queue.dequeue();
|
|
while (message !== null && !this.masterAbortController.signal.aborted) {
|
|
this.addMessage('user', message);
|
|
await this.runOneRound(message);
|
|
message = this.queue.dequeue();
|
|
}
|
|
|
|
if (this.masterAbortController.signal.aborted) {
|
|
this.setStatus(AgentStatus.CANCELLED);
|
|
} else {
|
|
this.settleRoundStatus();
|
|
}
|
|
} catch (err) {
|
|
this.error = err instanceof Error ? err.message : String(err);
|
|
this.setStatus(AgentStatus.FAILED);
|
|
debugLogger.error('AgentInteractive processing failed:', err);
|
|
} finally {
|
|
this.processing = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run a single reasoning round for one message.
|
|
* Creates a per-round AbortController so cancellation is scoped.
|
|
*/
|
|
private async runOneRound(message: string): Promise<void> {
|
|
if (!this.chat) return;
|
|
|
|
this.setStatus(AgentStatus.RUNNING);
|
|
this.lastRoundError = undefined;
|
|
this.roundCancelledByUser = false;
|
|
this.roundAbortController = createChildAbortController(
|
|
this.masterAbortController,
|
|
);
|
|
|
|
try {
|
|
const initialMessages = [
|
|
{ role: 'user' as const, parts: [{ text: message }] },
|
|
];
|
|
|
|
const result = await this.core.runReasoningLoop(
|
|
this.chat,
|
|
initialMessages,
|
|
this.toolsList,
|
|
this.roundAbortController,
|
|
{
|
|
maxTurns: this.config.maxTurnsPerMessage,
|
|
maxTimeMinutes: this.config.maxTimeMinutesPerMessage,
|
|
},
|
|
);
|
|
|
|
// Surface non-normal termination as a visible info message and as
|
|
// lastRoundError so Arena can distinguish limit stops from successes.
|
|
if (
|
|
result.terminateMode &&
|
|
result.terminateMode !== AgentTerminateMode.GOAL
|
|
) {
|
|
const msg = terminateModeMessage(result.terminateMode);
|
|
if (msg) {
|
|
this.addMessage('info', msg.text, { metadata: { level: msg.level } });
|
|
}
|
|
this.lastRoundError = `Terminated: ${result.terminateMode}`;
|
|
}
|
|
} catch (err) {
|
|
// User-initiated cancellation already logged by cancelCurrentRound().
|
|
if (this.roundCancelledByUser) return;
|
|
// Agent survives round errors — log and settle status in runLoop.
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
this.lastRoundError = errorMessage;
|
|
debugLogger.error('AgentInteractive round error:', err);
|
|
this.addMessage('info', errorMessage, { metadata: { level: 'error' } });
|
|
} finally {
|
|
// Helper's reverse-cleanup detaches the parent listener automatically
|
|
// when the round controller aborts; abort here so cleanup fires whether
|
|
// or not the round was already cancelled.
|
|
this.roundAbortController?.abort();
|
|
this.roundAbortController = undefined;
|
|
}
|
|
}
|
|
|
|
// ─── Cancellation ──────────────────────────────────────────
|
|
|
|
/**
|
|
* Cancel only the current reasoning round.
|
|
* Adds a visible "cancelled" info message and clears pending approvals.
|
|
*/
|
|
cancelCurrentRound(): void {
|
|
this.roundCancelledByUser = true;
|
|
this.roundAbortController?.abort();
|
|
this.core.clearPendingApprovals();
|
|
this.addMessage('info', 'Agent round cancelled.', {
|
|
metadata: { level: 'warning' },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Graceful shutdown: stop accepting messages and wait for current
|
|
* processing to finish.
|
|
*/
|
|
async shutdown(): Promise<void> {
|
|
this.queue.drain();
|
|
if (this.executionPromise) {
|
|
await this.executionPromise;
|
|
}
|
|
// If no processing cycle ever ran (no initialTask, no messages),
|
|
// ensure the agent reaches a terminal status.
|
|
if (!isTerminalStatus(this.status)) {
|
|
this.setStatus(AgentStatus.COMPLETED);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Immediate abort: cancel everything and set status to cancelled.
|
|
*/
|
|
abort(): void {
|
|
this.masterAbortController.abort();
|
|
this.queue.drain();
|
|
this.core.clearPendingApprovals();
|
|
}
|
|
|
|
// ─── Message Queue ─────────────────────────────────────────
|
|
|
|
/**
|
|
* Enqueue a message for the agent to process.
|
|
*/
|
|
enqueueMessage(message: string): void {
|
|
this.queue.enqueue(message);
|
|
if (!this.processing) {
|
|
this.executionPromise = this.runLoop();
|
|
}
|
|
}
|
|
|
|
// ─── State Accessors (delegates to AgentCore) ──────────────
|
|
|
|
getMessages(): readonly AgentMessage[] {
|
|
return this.core.getMessages();
|
|
}
|
|
|
|
getStatus(): AgentStatus {
|
|
return this.status;
|
|
}
|
|
|
|
getError(): string | undefined {
|
|
return this.error;
|
|
}
|
|
|
|
getLastRoundError(): string | undefined {
|
|
return this.lastRoundError;
|
|
}
|
|
|
|
getStats(): AgentStatsSummary {
|
|
return this.core.getExecutionSummary();
|
|
}
|
|
|
|
/** The prompt token count from the most recent model call. */
|
|
getLastPromptTokenCount(): number {
|
|
return this.core.lastPromptTokenCount;
|
|
}
|
|
|
|
getCore(): AgentCore {
|
|
return this.core;
|
|
}
|
|
|
|
getEventEmitter(): AgentEventEmitter {
|
|
return this.core.getEventEmitter();
|
|
}
|
|
|
|
/**
|
|
* Returns tool calls currently awaiting user approval.
|
|
* Keyed by callId → full ToolCallConfirmationDetails (with onConfirm).
|
|
* The UI reads this to render confirmation dialogs inside ToolGroupMessage.
|
|
*/
|
|
getPendingApprovals(): ReadonlyMap<string, ToolCallConfirmationDetails> {
|
|
return this.core.getPendingApprovals();
|
|
}
|
|
|
|
/**
|
|
* Returns live output for currently-executing tools.
|
|
* Keyed by callId → latest ToolResultDisplay (replaces on each update).
|
|
* Entries are cleared when TOOL_RESULT arrives for the call.
|
|
*/
|
|
getLiveOutputs(): ReadonlyMap<string, ToolResultDisplay> {
|
|
return this.core.getLiveOutputs();
|
|
}
|
|
|
|
/**
|
|
* Returns PTY PIDs for currently-executing interactive shell tools.
|
|
* Keyed by callId → PID. Populated from TOOL_OUTPUT_UPDATE when pid is
|
|
* present; cleared when TOOL_RESULT arrives. The UI uses this to enable
|
|
* interactive shell input via HistoryItemDisplay's activeShellPtyId prop.
|
|
*/
|
|
getShellPids(): ReadonlyMap<string, number> {
|
|
return this.core.getShellPids();
|
|
}
|
|
|
|
/**
|
|
* Returns wall-clock start timestamps (ms since epoch) for currently-
|
|
* executing tools, from the scheduler's `→ executing` transition.
|
|
* Keyed by callId; entries are cleared when TOOL_RESULT arrives. The UI
|
|
* uses this to render an elapsed-time indicator that excludes approval
|
|
* and scheduling wait.
|
|
*/
|
|
getExecutionStartTimes(): ReadonlyMap<string, number> {
|
|
return this.executionStartTimes;
|
|
}
|
|
|
|
/**
|
|
* Wait for the run loop to finish (used by InProcessBackend).
|
|
*/
|
|
async waitForCompletion(): Promise<void> {
|
|
if (this.executionPromise) {
|
|
await this.executionPromise;
|
|
}
|
|
}
|
|
|
|
// ─── Private Helpers ───────────────────────────────────────
|
|
|
|
/**
|
|
* Settle status after the run loop empties.
|
|
* On success → IDLE (agent stays alive for follow-up messages).
|
|
* On error → FAILED (terminal).
|
|
*/
|
|
private settleRoundStatus(): void {
|
|
if (this.lastRoundError && !this.roundCancelledByUser) {
|
|
this.setStatus(AgentStatus.FAILED);
|
|
} else {
|
|
this.setStatus(AgentStatus.IDLE);
|
|
}
|
|
}
|
|
|
|
private setStatus(newStatus: AgentStatus): void {
|
|
const previousStatus = this.status;
|
|
if (previousStatus === newStatus) return;
|
|
|
|
this.status = newStatus;
|
|
|
|
this.core.eventEmitter?.emit(AgentEventType.STATUS_CHANGE, {
|
|
agentId: this.config.agentId,
|
|
previousStatus,
|
|
newStatus,
|
|
roundCancelledByUser: this.roundCancelledByUser || undefined,
|
|
timestamp: Date.now(),
|
|
});
|
|
}
|
|
|
|
private addMessage(
|
|
role: AgentMessage['role'],
|
|
content: string,
|
|
options?: { thought?: boolean; metadata?: Record<string, unknown> },
|
|
): void {
|
|
this.core.pushMessage(role, content, options);
|
|
}
|
|
|
|
/**
|
|
* Wraps TOOL_WAITING_APPROVAL's onConfirm so a Cancel outcome aborts
|
|
* the current round (headless agents bypass this path entirely).
|
|
* Core already owns the message / live-output / shell-PID listeners.
|
|
*/
|
|
private setupEventListeners(): void {
|
|
const emitter = this.core.eventEmitter;
|
|
|
|
emitter.on(
|
|
AgentEventType.TOOL_OUTPUT_UPDATE,
|
|
(event: AgentToolOutputUpdateEvent) => {
|
|
if (
|
|
event.executionStartTime !== undefined &&
|
|
!this.executionStartTimes.has(event.callId)
|
|
) {
|
|
this.executionStartTimes.set(event.callId, event.executionStartTime);
|
|
}
|
|
},
|
|
);
|
|
|
|
emitter.on(AgentEventType.TOOL_RESULT, (event: AgentToolResultEvent) => {
|
|
this.executionStartTimes.delete(event.callId);
|
|
});
|
|
|
|
emitter.on(
|
|
AgentEventType.TOOL_WAITING_APPROVAL,
|
|
(event: AgentApprovalRequestEvent) => {
|
|
const fullDetails = {
|
|
...event.confirmationDetails,
|
|
onConfirm: async (
|
|
outcome: Parameters<ToolCallConfirmationDetails['onConfirm']>[0],
|
|
payload?: Parameters<ToolCallConfirmationDetails['onConfirm']>[1],
|
|
) => {
|
|
this.core.deletePendingApproval(event.callId);
|
|
// Nudge the UI to re-render so the tool transitions visually
|
|
// from Confirming → Executing without waiting for the first
|
|
// real TOOL_OUTPUT_UPDATE from the tool's execution.
|
|
this.core.eventEmitter.emit(AgentEventType.TOOL_OUTPUT_UPDATE, {
|
|
subagentId: this.core.subagentId,
|
|
round: event.round,
|
|
callId: event.callId,
|
|
outputChunk: '',
|
|
timestamp: Date.now(),
|
|
});
|
|
await event.respond(outcome, payload);
|
|
// When the user denies a tool, cancel the round immediately
|
|
// so the agent doesn't waste a turn "acknowledging" the denial.
|
|
if (outcome === ToolConfirmationOutcome.Cancel) {
|
|
this.cancelCurrentRound();
|
|
}
|
|
},
|
|
} as ToolCallConfirmationDetails;
|
|
|
|
this.core.setPendingApproval(event.callId, fullDetails);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Map a non-GOAL terminate mode to a visible status message for the UI,
|
|
* or return null to suppress the message entirely.
|
|
*
|
|
* CANCELLED is suppressed here because cancelCurrentRound() already emits
|
|
* its own warning. SHUTDOWN is suppressed as a normal lifecycle end.
|
|
*/
|
|
function terminateModeMessage(
|
|
mode: AgentTerminateMode,
|
|
): { text: string; level: 'info' | 'warning' | 'error' } | null {
|
|
switch (mode) {
|
|
case AgentTerminateMode.MAX_TURNS:
|
|
return {
|
|
text: 'Agent stopped: maximum turns reached.',
|
|
level: 'warning',
|
|
};
|
|
case AgentTerminateMode.TIMEOUT:
|
|
return { text: 'Agent stopped: time limit reached.', level: 'warning' };
|
|
case AgentTerminateMode.ERROR:
|
|
return { text: 'Agent stopped due to an error.', level: 'error' };
|
|
case AgentTerminateMode.CANCELLED:
|
|
case AgentTerminateMode.SHUTDOWN:
|
|
return null;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|