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
12 changes: 9 additions & 3 deletions packages/agent-core/src/loop/tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ interface PendingToolResult {
readonly args: unknown;
readonly result: ExecutableToolResult;
readonly stopTurn?: boolean | undefined;
readonly holdAccessUntil?: Promise<unknown> | undefined;
}

interface PreparedToolCallTask {
Expand Down Expand Up @@ -332,9 +333,13 @@ 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),
};
},
},
stopBatchAfterThis: execution.stopBatchAfterThis,
};
Expand Down Expand Up @@ -690,6 +695,7 @@ function makeToolResult(
args,
result,
stopTurn: toolResultStopsTurn(result),
holdAccessUntil: result.holdAccessUntil,
};
}

Expand Down
21 changes: 17 additions & 4 deletions packages/agent-core/src/loop/tool-scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,21 @@ import { ToolAccesses } from './tool-access';

export interface ToolCallTask<Result> {
readonly accesses: ToolAccesses;
readonly start: () => Promise<{ readonly result: Promise<Result> }>;
readonly start: () => Promise<{
readonly result: Promise<Result>;
readonly holdAccessUntil?: Promise<unknown> | undefined;
}>;
}

interface ScheduledToolCallTask<Result> extends ToolCallTask<Result> {
readonly result: ControlledPromise<Result>;
}

interface StartedToolCallTask<Result> {
readonly result: Promise<Result>;
readonly holdAccessUntil?: Promise<unknown> | undefined;
}

export class ToolScheduler<Result> {
private readonly activeTasks: Array<ScheduledToolCallTask<Result>> = [];
private queuedTasks: Array<ScheduledToolCallTask<Result>> = [];
Expand Down Expand Up @@ -63,7 +71,7 @@ export class ToolScheduler<Result> {

private start(task: ScheduledToolCallTask<Result>): void {
this.activeTasks.push(task);
let started: Promise<{ readonly result: Promise<Result> }>;
let started: Promise<StartedToolCallTask<Result>>;
try {
started = task.start();
} catch (error) {
Expand All @@ -73,8 +81,13 @@ export class ToolScheduler<Result> {
}

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);
});
Expand Down
8 changes: 8 additions & 0 deletions packages/agent-core/src/loop/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> | undefined;
/**
* Optional human-readable side channel for tool-result metadata that
* should not contaminate the data stream the model sees (e.g. a
Expand All @@ -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<unknown> | undefined;
/** See {@link ExecutableToolSuccessResult.truncated}. */
readonly truncated?: boolean | undefined;
}
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
93 changes: 89 additions & 4 deletions packages/agent-core/src/tools/builtin/shell/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,19 @@
import type { Kaos, KaosProcess } from '@moonshot-ai/kaos';
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 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 +88,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 +166,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 +199,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 +217,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 +262,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 +300,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 +341,32 @@ 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 waitForCompletionOrYield(completionOrDetach, args.yield_time_ms ?? 10_000)
: 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>`,
holdAccessUntil: completionOrDetach,
};
}
if (release === 'detached') {
collectForegroundOutput = false;
return this.backgroundStartedResult(
Expand Down Expand Up @@ -465,6 +528,28 @@ export class BashTool implements BuiltinTool<BashInput> {
}
}

function waitForCompletionOrYield(
completionOrDetach: Promise<ForegroundTaskReleaseReason | undefined>,
yieldMs: number,
): Promise<ForegroundTaskReleaseReason | undefined | 'yielded'> {
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;
Expand Down
Loading