Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-tools-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Let shell tool calls yield partial output before long-running commands finish.
3 changes: 2 additions & 1 deletion packages/agent-core/src/agent/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,10 +439,11 @@ export class BackgroundManager {
return this.toInfo(entry);
}

persistOutput(taskId: string): void {
async persistOutput(taskId: string): Promise<void> {
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. */
Expand Down
33 changes: 23 additions & 10 deletions packages/agent-core/src/agent/tool/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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();
}
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core/src/tools/builtin/shell/bash.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 65 additions & 3 deletions packages/agent-core/src/tools/builtin/shell/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.',
);
}

Expand Down Expand Up @@ -176,6 +196,7 @@ export class BashTool implements BuiltinTool<BashInput> {
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}`,
Expand All @@ -193,6 +214,19 @@ export class BashTool implements BuiltinTool<BashInput> {
};
}

async executeShellCommand(
args: BashInput,
context: ExecutableToolContext,
): Promise<ExecutableToolResult> {
return this.execution(
args,
context.signal,
context.onUpdate,
context.onForegroundTaskStart,
{ autoYield: false },
);
}

private spawn(effectiveCwd: string, command: string): Promise<KaosProcess> {
const shellCwd = this.isWindowsBash ? windowsPathToPosixPath(effectiveCwd) : effectiveCwd;
const shellArgs = [
Expand Down Expand Up @@ -225,6 +259,7 @@ export class BashTool implements BuiltinTool<BashInput> {
signal: AbortSignal,
onUpdate?: ((update: ToolUpdate) => void) | undefined,
onForegroundTaskStart?: ((taskId: string) => void) | undefined,
options: { readonly autoYield?: boolean } = {},
): Promise<ExecutableToolResult> {
const validationError = this.validateRunRequest(args, signal);
if (validationError !== undefined) return validationError;
Expand Down Expand Up @@ -262,7 +297,7 @@ export class BashTool implements BuiltinTool<BashInput> {
onUpdate?.({ kind, text });
builder.write(text);
if (!foregroundOutputPersisted && builder.truncated && foregroundTaskId !== undefined) {
this.backgroundManager.persistOutput(foregroundTaskId);
void this.backgroundManager.persistOutput(foregroundTaskId);
foregroundOutputPersisted = true;
}
};
Expand Down Expand Up @@ -303,7 +338,34 @@ export class BashTool implements BuiltinTool<BashInput> {
}

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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don’t default-yield manual shell commands

When this BashTool is used by ToolManager.runShellCommand for the TUI ! path, that caller resolves Bash with only { command, timeout: SHELL_FOREGROUND_TIMEOUT_S } and only treats results starting with task_id: as backgrounded. With this new 10s default, any manual shell command that runs longer than 10 seconds returns through the yielded branch, but its handle is embedded in result.output as a <system>...task_id... message while runShellCommand reports only the streamed stdout/stderr and drops the handle, leaving the process running without a visible way for the user to poll or cancel it. Please make yielding opt-in for this path or teach the shell-command caller to handle yielded results.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7e275e1: manual ! shell commands now call Bash through an internal no-auto-yield execution path, so they wait for completion, timeout, cancellation, or explicit ctrl+b detach instead of returning a hidden yielded task_id. Added a regression test in packages/agent-core/test/agent/context.test.ts.

])
: 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;
Comment on lines +351 to +355

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist logs when Bash auto-yields

When a foreground Bash call reaches yield_time_ms, this branch returns a task_id but does not detach the task or start output persistence, and then collectForegroundOutput is disabled. For a yielded command that emits more than TaskOutput's 32 KiB preview but less than the manager's 1 MiB spill threshold after the yield (for example, a long test ending with a 100 KiB log), TaskOutput has no output_path and only exposes the tail, so the head of the output is inaccessible to the model even though the result tells it to poll TaskOutput. Start persisting the task log when yielding.

Useful? React with 👍 / 👎.

const partialResult = builder.ok('');
const wallSeconds = ((args.yield_time_ms ?? 10_000) / 1000).toFixed(1);
const partialOutput = partialResult.output;
return {
isError: false,
Comment on lines +359 to +360

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep exclusive Bash access after auto-yield

When this return path resolves, the shell process is still running, but the loop releases the call's ToolAccesses.all() lock as soon as the tool result promise settles (tool-call.ts schedules access only around that promise). In a provider batch such as Bash("sleep 1; echo x > a", yield_time_ms=250) followed by a Read/Edit of a, the later tool can start while the yielded command is still mutating the workspace, so it can observe stale state or race with the shell despite Bash declaring exclusive access. Please stop/skip the remaining batch when yielding, or otherwise keep the exclusive scheduling boundary until the process is actually detached/completed.

Useful? React with 👍 / 👎.

output:
(partialOutput.length > 0 ? partialOutput + '\n\n' : '') +
`<system>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.</system>`,
};
}
if (release === 'detached') {
collectForegroundOutput = false;
return this.backgroundStartedResult(
Expand Down
65 changes: 64 additions & 1 deletion packages/agent-core/test/agent/context.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -133,6 +133,69 @@ describe('Agent context', () => {
expect(textOf(ctx.agent.context.history[1]!)).toContain('<bash-stdout>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<number>((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([]);
Expand Down
Loading