From 8b06072b1f6e6ce3d7e73c192ee0c9e9ac8d86c6 Mon Sep 17 00:00:00 2001 From: star Date: Sat, 27 Jun 2026 19:47:33 +0800 Subject: [PATCH 1/4] feat(bash): add yield_time_ms and explicit accesses --- .changeset/fresh-tools-march.md | 5 + .../agent-core/src/agent/background/index.ts | 3 +- packages/agent-core/src/agent/tool/index.ts | 33 +++-- .../src/tools/builtin/shell/bash.md | 2 + .../src/tools/builtin/shell/bash.ts | 68 ++++++++- .../agent-core/test/agent/context.test.ts | 65 ++++++++- packages/agent-core/test/tools/bash.test.ts | 129 +++++++++++++++++- 7 files changed, 289 insertions(+), 16 deletions(-) create mode 100644 .changeset/fresh-tools-march.md diff --git a/.changeset/fresh-tools-march.md b/.changeset/fresh-tools-march.md new file mode 100644 index 000000000..2d155f7c1 --- /dev/null +++ b/.changeset/fresh-tools-march.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Let shell tool calls yield partial output before long-running commands finish. diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index 6f7aaed3e..f87da675b 100644 --- a/packages/agent-core/src/agent/background/index.ts +++ b/packages/agent-core/src/agent/background/index.ts @@ -439,10 +439,11 @@ export class BackgroundManager { return this.toInfo(entry); } - persistOutput(taskId: string): void { + async persistOutput(taskId: string): Promise { const entry = this.tasks.get(taskId); if (entry === undefined) return; this.startOutputPersist(entry); + await Promise.all([entry.outputWriteQueue, this.persistLive(entry)]); } /** Stop a running task. SIGTERM → 5s grace → SIGKILL. */ diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index f16577253..290a39439 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -4,7 +4,7 @@ import picomatch from 'picomatch'; import type { Agent } from '..'; import { makeErrorPayload } from '../../errors'; -import type { ExecutableTool, ToolUpdate } from '../../loop'; +import type { ExecutableTool, ExecutableToolContext, ToolUpdate } from '../../loop'; import { createMcpAuthTool } from '../../mcp/auth-tool'; import type { McpConnectionManager, McpServerEntry } from '../../mcp'; import { mcpResultToExecutableOutput } from '../../mcp/output'; @@ -113,14 +113,7 @@ export class ToolManager { const controller = new AbortController(); if (commandId !== undefined) this.shellCommandControllers.set(commandId, controller); try { - const execution = await bash.resolveExecution({ command, timeout: SHELL_FOREGROUND_TIMEOUT_S }); - if (!('execute' in execution)) { - const output = - typeof execution.output === 'string' ? execution.output : 'Command failed.'; - this.agent.context.appendBashOutput('', output); - return { stdout: '', stderr: output, isError: true }; - } - const result = await execution.execute({ + const toolContext: ExecutableToolContext = { turnId: '', toolCallId: 'shell-command', signal: controller.signal, @@ -140,7 +133,13 @@ export class ToolManager { this.agent.emitEvent({ type: 'shell.started', commandId, taskId }); } }, - }); + }; + const result = await (bash instanceof b.BashTool + ? bash.executeShellCommand( + { command, timeout: SHELL_FOREGROUND_TIMEOUT_S }, + toolContext, + ) + : this.executeShellCommandViaTool(bash, command, toolContext)); isError = result.isError === true; // Detached to background (ctrl+b): the BashTool returns the background @@ -179,6 +178,20 @@ export class ToolManager { return { stdout, stderr, isError }; } + private async executeShellCommandViaTool( + bash: BuiltinTool, + command: string, + context: ExecutableToolContext, + ) { + const execution = await bash.resolveExecution({ command, timeout: SHELL_FOREGROUND_TIMEOUT_S }); + if (!('execute' in execution)) { + const output = + typeof execution.output === 'string' ? execution.output : 'Command failed.'; + return { output, isError: true as const }; + } + return execution.execute(context); + } + cancelShellCommand(commandId: string): void { this.shellCommandControllers.get(commandId)?.abort(); } diff --git a/packages/agent-core/src/tools/builtin/shell/bash.md b/packages/agent-core/src/tools/builtin/shell/bash.md index 7a6b97dd3..1eee3fa1a 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.md +++ b/packages/agent-core/src/tools/builtin/shell/bash.md @@ -15,6 +15,8 @@ The stdout and stderr will be combined and returned as a string. The output may If `run_in_background=true`, the command will be started as a background task and this tool will return a task ID instead of waiting for command completion. When doing that, you must provide a short `description`. Background commands default to a {{ DEFAULT_BACKGROUND_TIMEOUT_S }}s timeout and `timeout` is capped at {{ MAX_BACKGROUND_TIMEOUT_S }}s; set `disable_timeout=true` only when the task should run without a timeout. You will be automatically notified when the task completes. After starting one, default to returning control to the user instead of immediately waiting on it. Use `TaskOutput` for a non-blocking status/output snapshot, and only set `block=true` when you explicitly want to wait for completion. Use `TaskStop` only if the task must be cancelled. If a human user wants to inspect background tasks themselves, point them to the `/tasks` command, which opens an interactive panel; it has no subcommands. +**yield_time_ms:** Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms. The command continues running during this wait. If the command finishes within the yield window, the full result is returned immediately. Otherwise, partial output is returned with a `task_id` you can use to poll or stop the command. + **Guidelines for safety and security:** - Each shell tool call will be executed in a fresh shell environment. The shell variables, current working directory changes, and the shell history is not preserved between calls. To run a command in a particular directory, pass the `cwd` argument (or use absolute paths) rather than relying on a `cd` from an earlier call. - The tool call will return after the command is finished. You shall not use this tool to execute an interactive command or a command that may run forever. For possibly long-running foreground commands, set the `timeout` argument in seconds. Foreground commands default to {{ DEFAULT_TIMEOUT_S }}s and allow up to {{ MAX_TIMEOUT_S }}s. diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index 8198a9506..ebbb8e36e 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -23,11 +23,18 @@ */ import type { Kaos, KaosProcess } from '@moonshot-ai/kaos'; +import { sleep } from '@antfu/utils'; import { z } from 'zod'; import { ProcessBackgroundTask, type BackgroundManager } from '../../../agent/background'; import type { BuiltinTool } from '../../../agent/tool'; -import type { ExecutableToolResult, ToolExecution, ToolUpdate } from '../../../loop/types'; +import { ToolAccesses } from '../../../loop/tool-access'; +import type { + ExecutableToolContext, + ExecutableToolResult, + ToolExecution, + ToolUpdate, +} from '../../../loop/types'; import { renderPrompt } from '../../../utils/render-prompt'; import { toInputJsonSchema } from '../../support/input-schema'; import { literalRulePattern, matchesGlobRuleSubject } from '../../support/rule-match'; @@ -78,6 +85,15 @@ export const BashInputSchema = z .describe( 'If true, do not apply a timeout to the command. Only applies when run_in_background is true.', ), + yield_time_ms: z + .number() + .int() + .min(250) + .max(30000) + .optional() + .describe( + 'Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms. The command continues running during this wait.', + ), }) .superRefine((val, ctx) => { if (val.timeout === undefined) return; @@ -147,6 +163,10 @@ function withoutBackgroundDescription(description: string): string { .replace( /\r?\n- Prefer `run_in_background=true`[\s\S]*?conversation to continue before the command finishes\./, '\n- Do not set `run_in_background=true`; background task management tools are not available.', + ) + .replace( + /\n\n\*\*yield_time_ms:\*\*[\s\S]*?Otherwise, partial output is returned with a `task_id` you can use to poll or stop the command\./, + '\n\n**yield_time_ms:** Background task tools are disabled for this agent, so foreground commands do not yield a `task_id`; they wait for completion, timeout, detach, or cancellation.', ); } @@ -176,6 +196,7 @@ export class BashTool implements BuiltinTool { resolveExecution(args: BashInput): ToolExecution { const preview = args.command.length > 50 ? `${args.command.slice(0, 50)}…` : args.command; return { + accesses: ToolAccesses.all(), description: args.run_in_background ? `Starting background: ${preview}` : `Running: ${preview}`, @@ -193,6 +214,19 @@ export class BashTool implements BuiltinTool { }; } + async executeShellCommand( + args: BashInput, + context: ExecutableToolContext, + ): Promise { + return this.execution( + args, + context.signal, + context.onUpdate, + context.onForegroundTaskStart, + { autoYield: false }, + ); + } + private spawn(effectiveCwd: string, command: string): Promise { const shellCwd = this.isWindowsBash ? windowsPathToPosixPath(effectiveCwd) : effectiveCwd; const shellArgs = [ @@ -225,6 +259,7 @@ export class BashTool implements BuiltinTool { signal: AbortSignal, onUpdate?: ((update: ToolUpdate) => void) | undefined, onForegroundTaskStart?: ((taskId: string) => void) | undefined, + options: { readonly autoYield?: boolean } = {}, ): Promise { const validationError = this.validateRunRequest(args, signal); if (validationError !== undefined) return validationError; @@ -262,7 +297,7 @@ export class BashTool implements BuiltinTool { onUpdate?.({ kind, text }); builder.write(text); if (!foregroundOutputPersisted && builder.truncated && foregroundTaskId !== undefined) { - this.backgroundManager.persistOutput(foregroundTaskId); + void this.backgroundManager.persistOutput(foregroundTaskId); foregroundOutputPersisted = true; } }; @@ -303,7 +338,34 @@ export class BashTool implements BuiltinTool { } try { - const release = await this.backgroundManager.waitForForegroundRelease(taskId); + const completionOrDetach = this.backgroundManager.waitForForegroundRelease(taskId); + // Only yield a foreground command when the model can actually manage the + // resulting task. Subagent profiles can expose Bash without TaskOutput or + // TaskStop, so returning a task_id there would strand the command result. + const release = this.allowBackground && options.autoYield !== false + ? await Promise.race([ + completionOrDetach, + sleep(args.yield_time_ms ?? 10_000).then(() => 'yielded' as const), + ]) + : await completionOrDetach; + if (release === 'yielded') { + // Command is still running after yield_time_ms. Return partial output + // with wall_time_seconds so the caller knows how long we waited. + await this.backgroundManager.persistOutput(taskId); + collectForegroundOutput = false; + const partialResult = builder.ok(''); + const wallSeconds = ((args.yield_time_ms ?? 10_000) / 1000).toFixed(1); + const partialOutput = partialResult.output; + return { + isError: false, + output: + (partialOutput.length > 0 ? partialOutput + '\n\n' : '') + + `Command still running after ${wallSeconds}s yield. ` + + `task_id: ${taskId}\n` + + `Use TaskOutput(task_id="${taskId}", block=false) to poll for more output, ` + + `or TaskStop(task_id="${taskId}") to cancel.`, + }; + } if (release === 'detached') { collectForegroundOutput = false; return this.backgroundStartedResult( diff --git a/packages/agent-core/test/agent/context.test.ts b/packages/agent-core/test/agent/context.test.ts index 580bda69c..ce6b7f4f6 100644 --- a/packages/agent-core/test/agent/context.test.ts +++ b/packages/agent-core/test/agent/context.test.ts @@ -1,4 +1,4 @@ -import { Readable, type Writable } from 'node:stream'; +import { PassThrough, Readable, type Writable } from 'node:stream'; import type { KaosProcess } from '@moonshot-ai/kaos'; import type { Message } from '@moonshot-ai/kosong'; @@ -133,6 +133,69 @@ describe('Agent context', () => { expect(textOf(ctx.agent.context.history[1]!)).toContain('hello'); }); + it('does not auto-yield manual shell commands after the Bash yield window', async () => { + vi.useFakeTimers(); + try { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + let exitCode: number | null = null; + let resolveWait: (code: number) => void = () => {}; + const waitPromise = new Promise((resolve) => { + resolveWait = resolve; + }); + const proc: KaosProcess = { + stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable, + stdout, + stderr, + pid: 2, + get exitCode(): number | null { + return exitCode; + }, + wait: vi.fn(async () => waitPromise), + kill: vi.fn(async () => {}), + dispose: vi.fn(async () => { + stdout.destroy(); + stderr.destroy(); + }), + }; + const execWithEnv = vi.fn().mockResolvedValue(proc); + const kaos = createFakeKaos({ execWithEnv }); + const ctx = testAgent({ kaos }); + ctx.configure(); + + const running = ctx.agent.tools.runShellCommand('sleep 20'); + let settled = false; + void running.finally(() => { + settled = true; + }); + + await vi.waitFor(() => { + expect(execWithEnv).toHaveBeenCalledTimes(1); + }); + await vi.advanceTimersByTimeAsync(10_000); + + expect(settled).toBe(false); + + stdout.write('done\n'); + stdout.end(); + stderr.end(); + exitCode = 0; + resolveWait(0); + + const result = await running; + + expect(result).toMatchObject({ + stdout: 'done\n', + stderr: '', + isError: false, + }); + expect(result.backgrounded).toBeUndefined(); + expect(result.stdout).not.toContain('task_id:'); + } finally { + vi.useRealTimers(); + } + }); + it('surfaces the failure reason when a shell command fails with no output', async () => { const fakeProcess = (exitCode: number): KaosProcess => { const out = Readable.from([]); diff --git a/packages/agent-core/test/tools/bash.test.ts b/packages/agent-core/test/tools/bash.test.ts index 682aa26f2..102c04199 100644 --- a/packages/agent-core/test/tools/bash.test.ts +++ b/packages/agent-core/test/tools/bash.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { PassThrough, Readable, type Writable } from 'node:stream'; @@ -328,6 +328,18 @@ describe('BashTool', () => { } }); + it('does not expose sandbox permissions before execution supports them', () => { + const tool = bashTool(createFakeKaos({ osEnv: posixEnv }), '/workspace'); + const properties = (tool.parameters as { properties: Record }).properties; + + expect(properties).not.toHaveProperty('sandbox_permissions'); + expect(properties).not.toHaveProperty('additional_permissions'); + expect(properties).not.toHaveProperty('justification'); + expect(tool.description).not.toContain('sandbox_permissions'); + expect(tool.description).not.toContain('additional_permissions'); + expect(tool.description).not.toContain('require_escalated'); + }); + it('exposes a default timeout in the JSON Schema', () => { const tool = bashTool(createFakeKaos({ osEnv: posixEnv }), '/workspace'); const properties = (tool.parameters as { properties: Record }) @@ -669,6 +681,107 @@ describe('BashTool', () => { }); }); + it('does not yield a foreground task when background tools are disabled', async () => { + vi.useFakeTimers(); + try { + const { proc, finish } = pendingProcess(); + const manager = createBackgroundManager().manager; + const tool = bashTool( + createFakeKaos({ + execWithEnv: vi.fn().mockResolvedValue(proc), + osEnv: posixEnv, + }), + '/workspace', + manager, + { allowBackground: false }, + ); + + const running = executeTool( + tool, + context({ command: 'sleep 10', timeout: 60, yield_time_ms: 250 }), + ); + let settled = false; + void running.finally(() => { + settled = true; + }); + + await vi.waitFor(() => { + expect(manager.list(false)).toHaveLength(1); + }); + await vi.advanceTimersByTimeAsync(250); + + expect(settled).toBe(false); + const task = manager.list(false)[0]!; + expect(task).toMatchObject({ detached: false }); + + (proc.stdout as PassThrough).write('done\n'); + finish(); + const result = await running; + + expect(result).toMatchObject({ + isError: false, + message: 'Command executed successfully.', + }); + expect(result.output).toContain('done\n'); + expect(result.output).not.toContain('task_id:'); + expect(result.output).not.toContain('TaskOutput'); + expect(result.output).not.toContain('TaskStop'); + } finally { + vi.useRealTimers(); + } + }); + + it('persists complete output after yielding a foreground task', async () => { + vi.useFakeTimers(); + const sessionDir = mkdtempSync(join(tmpdir(), 'bash-yield-persist-')); + try { + const { proc, finish } = pendingProcess(); + const manager = createBackgroundManager({ sessionDir }).manager; + const tool = bashTool( + createFakeKaos({ + execWithEnv: vi.fn().mockResolvedValue(proc), + osEnv: posixEnv, + }), + '/workspace', + manager, + ); + + const running = executeTool( + tool, + context({ command: 'sleep 10', timeout: 60, yield_time_ms: 250 }), + ); + await vi.waitFor(() => { + expect(manager.list(false)).toHaveLength(1); + }); + const task = manager.list(false)[0]!; + + (proc.stdout as PassThrough).write('before yield\n'); + await vi.advanceTimersByTimeAsync(250); + const result = await running; + + expect(result.output).toContain(`task_id: ${task.taskId}`); + expect(existsSync(join(sessionDir, 'tasks', `${task.taskId}.json`))).toBe(true); + + (proc.stdout as PassThrough).write('after yield\n'); + await vi.waitFor(async () => { + const snapshot = await manager.getOutputSnapshot(task.taskId, 0); + expect(snapshot.fullOutputAvailable).toBe(true); + expect(snapshot.outputPath).toBeTruthy(); + }); + + const snapshot = await manager.getOutputSnapshot(task.taskId, 0); + expect(readFileSync(snapshot.outputPath!, 'utf8')).toBe('before yield\nafter yield\n'); + + finish(); + await expect(manager.wait(task.taskId)).resolves.toMatchObject({ + status: 'completed', + }); + } finally { + rmSync(sessionDir, { recursive: true, force: true }); + vi.useRealTimers(); + } + }); + it('keeps task metadata independent when noisy foreground output is capped before detach', async () => { const { proc, finish } = pendingProcess(); const manager = createBackgroundManager().manager; @@ -1301,4 +1414,18 @@ describe('BashTool prompt / runtime consistency', () => { // (`Command failed with exit code: N`), never via a system tag. expect(tool.description).not.toMatch(/exit code will be provided in a system tag/); }); + + it('documents yield_time_ms without task polling when background tools are disabled', () => { + const tool = bashTool( + createFakeKaos({ osEnv: posixEnv }), + '/workspace', + createBackgroundManager().manager, + { allowBackground: false }, + ); + + expect(tool.description).toContain('**yield_time_ms:**'); + expect(tool.description).toContain('foreground commands do not yield a `task_id`'); + expect(tool.description).not.toContain('TaskOutput'); + expect(tool.description).not.toContain('TaskStop'); + }); }); From 85466f3d7eeee7c2b33adb958f760ce0036d1c59 Mon Sep 17 00:00:00 2001 From: star Date: Tue, 30 Jun 2026 12:04:35 +0800 Subject: [PATCH 2/4] fix(agent-core): keep Bash access held after auto-yield Keep scheduler conflicts blocked until yielded foreground Bash commands complete or detach, so later provider-batch tools cannot race with an active shell mutation. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/agent-core/src/loop/tool-call.ts | 22 +++++++--- .../agent-core/src/loop/tool-scheduler.ts | 34 ++++++++++++++-- packages/agent-core/src/loop/types.ts | 8 ++++ .../src/tools/builtin/shell/bash.ts | 1 + .../test/loop/tool-scheduler.test.ts | 40 ++++++++++++++++++- 5 files changed, 94 insertions(+), 11 deletions(-) diff --git a/packages/agent-core/src/loop/tool-call.ts b/packages/agent-core/src/loop/tool-call.ts index 7379406ce..a4801efa5 100644 --- a/packages/agent-core/src/loop/tool-call.ts +++ b/packages/agent-core/src/loop/tool-call.ts @@ -44,6 +44,7 @@ import type { const GRACE_TIMEOUT_MS = 2_000; const TOOL_OUTPUT_EMPTY = 'Tool output is empty.'; const TOOL_OUTPUT_NON_TEXT = 'Tool returned non-text content.'; +const STOPPED_TURN_SKIP_OUTPUT = 'Tool skipped because a previous tool call stopped the turn.'; const validators = new WeakMap(); @@ -106,6 +107,7 @@ interface PendingToolResult { readonly args: unknown; readonly result: ExecutableToolResult; readonly stopTurn?: boolean | undefined; + readonly holdAccessUntil?: Promise | undefined; } interface PreparedToolCallTask { @@ -151,7 +153,10 @@ export async function runToolCallBatch( // paired `tool.result`; the caller checks abort before writing `step.end`. for (const pendingResult of pendingResults) { const result = await finalizePendingToolResult(batchStep, await pendingResult); - if (result.stopTurn === true) stopTurn = true; + if (result.stopTurn === true) { + stopTurn = true; + scheduler.cancelQueued(); + } await step.dispatchEvent({ type: 'tool.result', parentUuid: result.toolCall.id, @@ -332,9 +337,14 @@ async function prepareToolCall( return { task: { accesses: execution.accesses ?? ToolAccesses.all(), - start: async () => ({ - result: runRunnableToolCall(step, call, effectiveArgs, executionMetadata, execution), - }), + start: async () => { + const result = runRunnableToolCall(step, call, effectiveArgs, executionMetadata, execution); + return { + result, + holdAccessUntil: result.then((pendingResult) => pendingResult.holdAccessUntil), + }; + }, + cancel: () => makeErrorToolResult(call, effectiveArgs, STOPPED_TURN_SKIP_OUTPUT), }, stopBatchAfterThis: execution.stopBatchAfterThis, }; @@ -344,9 +354,8 @@ async function prepareSkippedToolCall( step: ToolCallBatchContext, call: PreflightedToolCall, ): Promise> { - const output = 'Tool skipped because a previous tool call stopped the turn.'; await dispatchToolCall(step, call, call.args); - return makeResolvedToolCallTask(makeErrorToolResult(call, call.args, output)); + return makeResolvedToolCallTask(makeErrorToolResult(call, call.args, STOPPED_TURN_SKIP_OUTPUT)); } function makeResolvedToolCallTask(result: PendingToolResult): ToolCallTask { @@ -690,6 +699,7 @@ function makeToolResult( args, result, stopTurn: toolResultStopsTurn(result), + holdAccessUntil: result.holdAccessUntil, }; } diff --git a/packages/agent-core/src/loop/tool-scheduler.ts b/packages/agent-core/src/loop/tool-scheduler.ts index 0976d3fed..ce00a5ec8 100644 --- a/packages/agent-core/src/loop/tool-scheduler.ts +++ b/packages/agent-core/src/loop/tool-scheduler.ts @@ -18,13 +18,22 @@ import { ToolAccesses } from './tool-access'; export interface ToolCallTask { readonly accesses: ToolAccesses; - readonly start: () => Promise<{ readonly result: Promise }>; + readonly start: () => Promise<{ + readonly result: Promise; + readonly holdAccessUntil?: Promise | undefined; + }>; + readonly cancel?: (() => Result) | undefined; } interface ScheduledToolCallTask extends ToolCallTask { readonly result: ControlledPromise; } +interface StartedToolCallTask { + readonly result: Promise; + readonly holdAccessUntil?: Promise | undefined; +} + export class ToolScheduler { private readonly activeTasks: Array> = []; private queuedTasks: Array> = []; @@ -43,6 +52,18 @@ export class ToolScheduler { return result; } + cancelQueued(): void { + const queuedTasks = this.queuedTasks; + this.queuedTasks = []; + for (const task of queuedTasks) { + if (task.cancel !== undefined) { + task.result.resolve(task.cancel()); + } else { + task.result.reject(new Error('Queued tool task was cancelled.')); + } + } + } + private isBlocked( task: ToolCallTask, queuedBefore: readonly ToolCallTask[], @@ -63,7 +84,7 @@ export class ToolScheduler { private start(task: ScheduledToolCallTask): void { this.activeTasks.push(task); - let started: Promise<{ readonly result: Promise }>; + let started: Promise>; try { started = task.start(); } catch (error) { @@ -73,8 +94,13 @@ export class ToolScheduler { } void started - .then(({ result }) => result) - .then(task.result.resolve, task.result.reject) + .then(({ result, holdAccessUntil }) => { + void result.then(task.result.resolve, task.result.reject); + return (holdAccessUntil ?? result).catch(() => undefined); + }) + .catch((error) => { + task.result.reject(error); + }) .finally(() => { this.finish(task); }); diff --git a/packages/agent-core/src/loop/types.ts b/packages/agent-core/src/loop/types.ts index 9d290f235..e17f74128 100644 --- a/packages/agent-core/src/loop/types.ts +++ b/packages/agent-core/src/loop/types.ts @@ -70,6 +70,12 @@ export interface ExecutableToolSuccessResult { * later tool calls in the same batch are allowed. */ readonly stopTurn?: boolean | undefined; + /** + * Internal scheduling hint. The tool result can be returned to the model + * immediately, but conflicting queued tools must not start until this promise + * settles. Tool result persistence strips this field. + */ + readonly holdAccessUntil?: Promise | undefined; /** * Optional human-readable side channel for tool-result metadata that * should not contaminate the data stream the model sees (e.g. a @@ -93,6 +99,8 @@ export interface ExecutableToolErrorResult { readonly message?: string | undefined; /** See {@link ExecutableToolSuccessResult.stopTurn}. */ readonly stopTurn?: boolean | undefined; + /** See {@link ExecutableToolSuccessResult.holdAccessUntil}. */ + readonly holdAccessUntil?: Promise | undefined; /** See {@link ExecutableToolSuccessResult.truncated}. */ readonly truncated?: boolean | undefined; } diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index ebbb8e36e..9001dc512 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -364,6 +364,7 @@ export class BashTool implements BuiltinTool { `task_id: ${taskId}\n` + `Use TaskOutput(task_id="${taskId}", block=false) to poll for more output, ` + `or TaskStop(task_id="${taskId}") to cancel.`, + holdAccessUntil: completionOrDetach, }; } if (release === 'detached') { diff --git a/packages/agent-core/test/loop/tool-scheduler.test.ts b/packages/agent-core/test/loop/tool-scheduler.test.ts index 4dfc21515..5f09c7d63 100644 --- a/packages/agent-core/test/loop/tool-scheduler.test.ts +++ b/packages/agent-core/test/loop/tool-scheduler.test.ts @@ -216,6 +216,35 @@ describe('ToolScheduler', () => { expect(drained).toEqual(['reader', 'exclusive']); }); + it('keeps conflicting accesses blocked until a yielded task releases its hold', async () => { + const started: string[] = []; + const drained: string[] = []; + const scheduler = makeScheduler(drained); + const exclusive = makeControlledTask('exclusive', ToolAccesses.all(), started, { + holdAccess: true, + }); + const reader = makeControlledTask('reader', readPath('/repo/a.ts'), started); + + scheduler.add(exclusive.task); + scheduler.add(reader.task); + await waitOneMacrotask(); + + expect(started).toEqual(['exclusive']); + + exclusive.resolve(); + await waitOneMacrotask(); + expect(drained).toEqual([]); + expect(started).toEqual(['exclusive']); + + exclusive.releaseHold(); + await waitOneMacrotask(); + expect(started).toEqual(['exclusive', 'reader']); + + reader.resolve(); + await scheduler.collectResults(); + expect(drained).toEqual(['exclusive', 'reader']); + }); + it('dispatches submitted results in provider order', async () => { const started: string[] = []; const drained: string[] = []; @@ -237,6 +266,7 @@ interface ControlledTask { readonly task: ToolCallTask; readonly resolve: () => void; readonly reject: (error: unknown) => void; + readonly releaseHold: () => void; } function makeScheduler(drained: string[]): { @@ -265,6 +295,7 @@ function makeControlledTask( name: string, accesses: ToolAccesses, startedNames: string[], + options: { readonly holdAccess?: boolean } = {}, ): ControlledTask { let resolveResult: (value: string) => void = () => {}; let rejectResult: (error: unknown) => void = () => {}; @@ -272,13 +303,19 @@ function makeControlledTask( resolveResult = resolve; rejectResult = reject; }); + let releaseHold: () => void = () => {}; + const holdAccessUntil = options.holdAccess === true + ? new Promise((resolve) => { + releaseHold = resolve; + }) + : undefined; return { task: { accesses, start: async () => { startedNames.push(name); - return { result }; + return { result, holdAccessUntil }; }, }, resolve: () => { @@ -287,6 +324,7 @@ function makeControlledTask( reject: (error) => { rejectResult(error); }, + releaseHold, }; } From d6e52958ab056ada8e8498e4018ef33a63580bef Mon Sep 17 00:00:00 2001 From: star Date: Tue, 30 Jun 2026 12:09:50 +0800 Subject: [PATCH 3/4] refactor(agent-core): narrow Bash yield scheduler follow-up Remove the out-of-scope queued-cancel draft from the Bash yield follow-up so the PR only keeps access locks held until yielded commands settle. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/agent-core/src/loop/tool-call.ts | 10 +++------- packages/agent-core/src/loop/tool-scheduler.ts | 13 ------------- 2 files changed, 3 insertions(+), 20 deletions(-) diff --git a/packages/agent-core/src/loop/tool-call.ts b/packages/agent-core/src/loop/tool-call.ts index a4801efa5..2514408e2 100644 --- a/packages/agent-core/src/loop/tool-call.ts +++ b/packages/agent-core/src/loop/tool-call.ts @@ -44,7 +44,6 @@ import type { const GRACE_TIMEOUT_MS = 2_000; const TOOL_OUTPUT_EMPTY = 'Tool output is empty.'; const TOOL_OUTPUT_NON_TEXT = 'Tool returned non-text content.'; -const STOPPED_TURN_SKIP_OUTPUT = 'Tool skipped because a previous tool call stopped the turn.'; const validators = new WeakMap(); @@ -153,10 +152,7 @@ export async function runToolCallBatch( // paired `tool.result`; the caller checks abort before writing `step.end`. for (const pendingResult of pendingResults) { const result = await finalizePendingToolResult(batchStep, await pendingResult); - if (result.stopTurn === true) { - stopTurn = true; - scheduler.cancelQueued(); - } + if (result.stopTurn === true) stopTurn = true; await step.dispatchEvent({ type: 'tool.result', parentUuid: result.toolCall.id, @@ -344,7 +340,6 @@ async function prepareToolCall( holdAccessUntil: result.then((pendingResult) => pendingResult.holdAccessUntil), }; }, - cancel: () => makeErrorToolResult(call, effectiveArgs, STOPPED_TURN_SKIP_OUTPUT), }, stopBatchAfterThis: execution.stopBatchAfterThis, }; @@ -354,8 +349,9 @@ async function prepareSkippedToolCall( step: ToolCallBatchContext, call: PreflightedToolCall, ): Promise> { + const output = 'Tool skipped because a previous tool call stopped the turn.'; await dispatchToolCall(step, call, call.args); - return makeResolvedToolCallTask(makeErrorToolResult(call, call.args, STOPPED_TURN_SKIP_OUTPUT)); + return makeResolvedToolCallTask(makeErrorToolResult(call, call.args, output)); } function makeResolvedToolCallTask(result: PendingToolResult): ToolCallTask { diff --git a/packages/agent-core/src/loop/tool-scheduler.ts b/packages/agent-core/src/loop/tool-scheduler.ts index ce00a5ec8..f06d43217 100644 --- a/packages/agent-core/src/loop/tool-scheduler.ts +++ b/packages/agent-core/src/loop/tool-scheduler.ts @@ -22,7 +22,6 @@ export interface ToolCallTask { readonly result: Promise; readonly holdAccessUntil?: Promise | undefined; }>; - readonly cancel?: (() => Result) | undefined; } interface ScheduledToolCallTask extends ToolCallTask { @@ -52,18 +51,6 @@ export class ToolScheduler { return result; } - cancelQueued(): void { - const queuedTasks = this.queuedTasks; - this.queuedTasks = []; - for (const task of queuedTasks) { - if (task.cancel !== undefined) { - task.result.resolve(task.cancel()); - } else { - task.result.reject(new Error('Queued tool task was cancelled.')); - } - } - } - private isBlocked( task: ToolCallTask, queuedBefore: readonly ToolCallTask[], From 5050b44129ccad2b77227bee4b16e454ca808bea Mon Sep 17 00:00:00 2001 From: starSumi Date: Tue, 30 Jun 2026 12:26:51 +0800 Subject: [PATCH 4/4] fix(agent-core): clear unused Bash yield timers Cancel the auto-yield timeout when foreground Bash completes or detaches before the yield window, and cover the fast-completion path with fake timers. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/tools/builtin/shell/bash.ts | 34 +++++++++++++--- packages/agent-core/test/tools/bash.test.ts | 39 +++++++++++++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index 9001dc512..4924769a4 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -23,10 +23,13 @@ */ import type { Kaos, KaosProcess } from '@moonshot-ai/kaos'; -import { sleep } from '@antfu/utils'; import { z } from 'zod'; -import { ProcessBackgroundTask, type BackgroundManager } from '../../../agent/background'; +import { + ProcessBackgroundTask, + type BackgroundManager, + type ForegroundTaskReleaseReason, +} from '../../../agent/background'; import type { BuiltinTool } from '../../../agent/tool'; import { ToolAccesses } from '../../../loop/tool-access'; import type { @@ -343,10 +346,7 @@ export class BashTool implements BuiltinTool { // resulting task. Subagent profiles can expose Bash without TaskOutput or // TaskStop, so returning a task_id there would strand the command result. const release = this.allowBackground && options.autoYield !== false - ? await Promise.race([ - completionOrDetach, - sleep(args.yield_time_ms ?? 10_000).then(() => 'yielded' as const), - ]) + ? await waitForCompletionOrYield(completionOrDetach, args.yield_time_ms ?? 10_000) : await completionOrDetach; if (release === 'yielded') { // Command is still running after yield_time_ms. Return partial output @@ -528,6 +528,28 @@ export class BashTool implements BuiltinTool { } } +function waitForCompletionOrYield( + completionOrDetach: Promise, + yieldMs: number, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + resolve('yielded'); + }, yieldMs); + + void completionOrDetach.then( + (release) => { + clearTimeout(timer); + resolve(release); + }, + (error: unknown) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + function backgroundResultMessage(title: string, suffix: string): string { const normalized = title.endsWith('.') ? title : `${title}.`; if (suffix.length === 0) return normalized; diff --git a/packages/agent-core/test/tools/bash.test.ts b/packages/agent-core/test/tools/bash.test.ts index 102c04199..ab26f8e39 100644 --- a/packages/agent-core/test/tools/bash.test.ts +++ b/packages/agent-core/test/tools/bash.test.ts @@ -731,6 +731,45 @@ describe('BashTool', () => { } }); + it('clears the yield timer when a foreground task finishes before auto-yield', async () => { + vi.useFakeTimers(); + try { + const { proc, finish } = pendingProcess(); + const manager = createBackgroundManager().manager; + const tool = bashTool( + createFakeKaos({ + execWithEnv: vi.fn().mockResolvedValue(proc), + osEnv: posixEnv, + }), + '/workspace', + manager, + ); + + const running = executeTool( + tool, + context({ command: 'echo done', timeout: 60, yield_time_ms: 10_000 }), + ); + + await vi.waitFor(() => { + expect(manager.list(false)).toHaveLength(1); + }); + + (proc.stdout as PassThrough).write('done\n'); + finish(); + const result = await running; + + expect(result).toMatchObject({ + isError: false, + message: 'Command executed successfully.', + }); + expect(result.output).toContain('done\n'); + expect(result.output).not.toContain('task_id:'); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + it('persists complete output after yielding a foreground task', async () => { vi.useFakeTimers(); const sessionDir = mkdtempSync(join(tmpdir(), 'bash-yield-persist-'));