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/stale-guard-execute-recheck.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Edit and Write now recheck the target file's on-disk state immediately before writing.
2 changes: 2 additions & 0 deletions packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ToolResult } from '#/tool/toolContract';
import type {
BeforeToolExecuteEvent,
ToolDidExecuteContext,
ToolExecuteContext,
WillExecuteToolEvent,
} from '#/agent/toolExecutor/toolHooks';
import type { ToolCall } from '#/kosong/contract/message';
Expand Down Expand Up @@ -50,6 +51,7 @@ export interface IAgentToolExecutorService {
readonly onWillExecuteTool: Event<WillExecuteToolEvent>;

readonly hooks: {
readonly onExecuteTool: OrderedHookSlot<ToolExecuteContext>;
readonly onDidExecuteTool: OrderedHookSlot<ToolDidExecuteContext>;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { parseToolCallArguments } from '#/tool/tool-args-parse';
import { PathSecurityError } from '#/tool/path-access';
import { isAbortError, isUserCancellation } from '#/_base/utils/abort';
import { BugIndicatingError } from '#/errors';
import { IEventDispatcher } from '#/state/eventDispatcher';
import {
ToolAccesses,
Expand All @@ -29,7 +30,9 @@ import type {
BeforeToolExecuteEvent,
ResolvedToolExecutionHookContext,
ToolDidExecuteContext,
ToolExecuteContext,
ToolExecutionOutcome,
ToolExecutionRunResult,
WillExecuteToolEvent,
} from '#/agent/toolExecutor/toolHooks';
import { IAgentStateService } from '#/agent/state/agentState';
Expand Down Expand Up @@ -66,11 +69,6 @@ export interface ToolExecutionTask {
readonly execute: (signal: AbortSignal) => Promise<ToolExecutionRunResult>;
}

export interface ToolExecutionRunResult {
readonly result: ToolResult;
readonly outcome: ToolExecutionOutcome;
}

interface TimedToolResult {
readonly index: number;
readonly result: ToolResult;
Expand Down Expand Up @@ -115,6 +113,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService {
readonly onWillExecuteTool: Event<WillExecuteToolEvent> = this.willExecuteEmitter.event;

readonly hooks = {
onExecuteTool: new OrderedHookSlot<ToolExecuteContext>(),
onDidExecuteTool: new OrderedHookSlot<ToolDidExecuteContext>(),
};

Expand Down Expand Up @@ -516,6 +515,34 @@ export class AgentToolExecutorService implements IAgentToolExecutorService {
};
}

const ctx: ToolExecuteContext = {
turnId: options.turnId,
signal,
trace: options.trace,
toolCall: call.toolCall,
tool: call.tool,
args: call.args,
execution,
metadata,
};
await this.hooks.onExecuteTool.run(ctx, async (c) => {
c.result = await this.executeResolvedTool(call, execution, metadata, options, signal);
});
if (ctx.result === undefined) {
throw new BugIndicatingError(
`onExecuteTool hook chain for tool "${call.toolName}" completed without producing a result`,
);
}
return ctx.result;
}

private async executeResolvedTool(
call: RunnableToolCall,
execution: RunnableToolExecution,
metadata: unknown,
options: ToolExecutorExecuteOptions,
signal: AbortSignal,
): Promise<ToolExecutionRunResult> {
let rawResult: ExecutableToolResult;
try {
const executePromise = execution.execute({
Expand Down
18 changes: 18 additions & 0 deletions packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ExecutableToolResult,
RunnableToolExecution,
ToolAccesses,
ToolResult,
} from '#/tool/toolContract';

export interface ToolExecutionHookContext {
Expand Down Expand Up @@ -51,6 +52,23 @@ export type ToolExecutionOutcome =
| 'synthetic'
| 'skipped';

export interface ToolExecutionRunResult {
readonly result: ToolResult;
readonly outcome: ToolExecutionOutcome;
}

export interface ToolExecuteContext {
readonly turnId: number;
readonly signal: AbortSignal;
readonly trace?: LLMRequestTrace;
readonly toolCall: ToolCall;
readonly tool: ExecutableTool;
readonly args: unknown;
readonly execution: RunnableToolExecution;
readonly metadata: unknown;
result?: ToolExecutionRunResult;
}

export interface ToolDidExecuteContext extends ToolExecutionHookContext {
readonly outcome: ToolExecutionOutcome;
readonly accesses?: ToolAccesses;
Expand Down
45 changes: 33 additions & 12 deletions packages/agent-core-v2/src/features/staleGuard/staleGuardService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent';
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import type {
BeforeToolExecuteEvent,
ToolDidExecuteContext,
ToolExecuteContext,
} from '#/agent/toolExecutor/toolHooks';
import type { ToolCall } from '#/kosong/contract/message';
import type { HostFileStat } from '#/os/interface/hostFileSystem';
Expand Down Expand Up @@ -56,9 +56,8 @@ export class StaleGuardService extends Disposable implements IStaleGuardService
this.states.contributeState(staleGuardKey);
this._register(toolExecutor.onBeforeExecuteTool((event) => this.guardWrite(event)));
this._register(
toolExecutor.hooks.onDidExecuteTool.register('staleGuard', async (ctx, next) => {
await this.observeExecution(ctx);
await next();
toolExecutor.hooks.onExecuteTool.register('staleGuard', async (ctx, next) => {
await this.executeWithGuard(ctx, next);
}),
);
this._register(
Expand All @@ -85,18 +84,40 @@ export class StaleGuardService extends Disposable implements IStaleGuardService
});
}

private async observeExecution(ctx: ToolDidExecuteContext): Promise<void> {
if (ctx.outcome !== 'executed' || ctx.result.isError === true) return;
private async executeWithGuard(
ctx: ToolExecuteContext,
next: () => Promise<void>,
): Promise<void> {
const name = ctx.toolCall.name;
if (name === 'Read') {
const path = accessedFilePath(ctx.accesses, READ_OPERATIONS);
if (path !== undefined) await this.recordCurrentMtime(path);
if (name === 'Edit' || name === 'Write') {
const path = accessedFilePath(ctx.execution.accesses, WRITE_OPERATIONS);
if (path !== undefined) {
const displayPath = stringArg(ctx.args, 'path') ?? path;
const error = await this.checkWritable(path, displayPath);
if (error !== undefined) {
ctx.result = { result: denyToolExecution(error), outcome: 'vetoed' };
return;
}
}
await next();
await this.observeExecuted(ctx, WRITE_OPERATIONS);
return;
}
if (name === 'Edit' || name === 'Write') {
const path = accessedFilePath(ctx.accesses, WRITE_OPERATIONS);
if (path !== undefined) await this.recordCurrentMtime(path);
if (name === 'Read') {
await next();
await this.observeExecuted(ctx, READ_OPERATIONS);
return;
}
await next();
}

private async observeExecuted(
ctx: ToolExecuteContext,
operations: readonly ToolFileAccessOperation[],
): Promise<void> {
if (ctx.result?.outcome !== 'executed' || ctx.result.result.isError === true) return;
const path = accessedFilePath(ctx.execution.accesses, operations);
if (path !== undefined) await this.recordCurrentMtime(path);
}

private async checkWritable(path: string, displayPath: string): Promise<string | undefined> {
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-core-v2/test/agent/loop/stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { IAgentLoopService, LoopErrorHandler, LoopErrorHandlerRegistrationO
import type { StepRequest } from '#/agent/loop/stepRequest';
import { StepRequestQueue, type StepRequestBatch } from '#/agent/loop/stepRequestQueue';
import type { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import type { BeforeToolExecuteEvent, ToolDidExecuteContext, WillExecuteToolEvent } from '#/agent/toolExecutor/toolHooks';
import type { BeforeToolExecuteEvent, ToolDidExecuteContext, ToolExecuteContext, WillExecuteToolEvent } from '#/agent/toolExecutor/toolHooks';
import { OrderedHookSlot } from '#/hooks';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { createHooks } from '#/hooks';
Expand Down Expand Up @@ -92,4 +92,4 @@ export async function runWillBeginStepHooks(
});
}
export function stubWire(): IWireService { return { _serviceBrand: undefined, seal: async () => {}, appendRecord: () => {}, readJournal: async function* () {}, flush: async () => {} }; }
export function stubToolExecutor(): IAgentToolExecutorService { return { _serviceBrand: undefined, execute: async function* () {}, onBeforeExecuteTool: Event.None as Event<BeforeToolExecuteEvent>, onWillExecuteTool: Event.None as Event<WillExecuteToolEvent>, hooks: { onDidExecuteTool: new OrderedHookSlot<ToolDidExecuteContext>() }, recordDupType: () => {}, registerToolCallGuard: () => ({ dispose() {} }), registerUnavailableToolDescriber: () => ({ dispose() {} }), registerMissingToolDescriber: () => ({ dispose() {} }) }; }
export function stubToolExecutor(): IAgentToolExecutorService { return { _serviceBrand: undefined, execute: async function* () {}, onBeforeExecuteTool: Event.None as Event<BeforeToolExecuteEvent>, onWillExecuteTool: Event.None as Event<WillExecuteToolEvent>, hooks: { onExecuteTool: new OrderedHookSlot<ToolExecuteContext>(), onDidExecuteTool: new OrderedHookSlot<ToolDidExecuteContext>() }, recordDupType: () => {}, registerToolCallGuard: () => ({ dispose() {} }), registerUnavailableToolDescriber: () => ({ dispose() {} }), registerMissingToolDescriber: () => ({ dispose() {} }) }; }
4 changes: 3 additions & 1 deletion packages/agent-core-v2/test/agent/toolExecutor/stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
BeforeExecuteDecision,
ResolvedToolExecutionHookContext,
ToolDidExecuteContext,
ToolExecuteContext,
WillExecuteToolEvent,
} from '#/agent/toolExecutor/toolHooks';
import { OrderedHookSlot } from '#/hooks';
Expand All @@ -25,12 +26,13 @@ export function stubToolExecutorEvents(): ToolExecutorEventStubs {
const beforeEmitter = new BeforeToolExecuteEmitter();
const willEmitter = new AsyncEmitter<WillExecuteToolEvent>();
const didExecuteSlot = new OrderedHookSlot<ToolDidExecuteContext>();
const executeSlot = new OrderedHookSlot<ToolExecuteContext>();
const executor: IAgentToolExecutorService = {
_serviceBrand: undefined,
execute: async function* () {},
onBeforeExecuteTool: beforeEmitter.event,
onWillExecuteTool: willEmitter.event,
hooks: { onDidExecuteTool: didExecuteSlot },
hooks: { onExecuteTool: executeSlot, onDidExecuteTool: didExecuteSlot },
recordDupType: () => {},
registerToolCallGuard: () => ({ dispose() {} }),
registerUnavailableToolDescriber: () => ({ dispose() {} }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,71 @@ describe('AgentToolExecutorService', () => {
}),
});
});

it('runs onExecuteTool middleware inside the scheduled task around the execution', async () => {
const first = new ControlledTool('first', ToolAccesses.writeFile('/repo/a.ts'));
const second = new TestTool('second', { accesses: ToolAccesses.writeFile('/repo/a.ts') });
registry.register(first);
registry.register(second);
const order: string[] = [];
executor.hooks.onExecuteTool.register('observe', async (ctx, next) => {
order.push(`before:${ctx.toolCall.id}`);
await next();
order.push(`after:${ctx.toolCall.id}`);
});

const execution = execute([
toolCall('call_first', 'first', {}),
toolCall('call_second', 'second', {}),
]);
await first.started;
expect(order).toEqual(['before:call_first']);
const results = await execution;

expect(order).toEqual([
'before:call_first',
'after:call_first',
'before:call_second',
'after:call_second',
]);
expect(results).toHaveLength(2);
});

it('an onExecuteTool middleware can veto the call without running the tool', async () => {
const tool = new TestTool('echo');
registry.register(tool);
const outcomes = new Map<string, ToolExecutionOutcome>();
executor.hooks.onDidExecuteTool.register('capture-outcomes', async (ctx, next) => {
outcomes.set(ctx.toolCall.id, ctx.outcome);
await next();
});
executor.hooks.onExecuteTool.register('veto', (ctx) => {
ctx.result = {
result: { output: 'vetoed at run time', isError: true },
outcome: 'vetoed',
};
});

const results = await execute([toolCall('call_echo', 'echo', { text: 'hi' })]);

expect(tool.calls).toEqual([]);
expect(results).toEqual([
expect.objectContaining({ output: 'vetoed at run time', isError: true }),
]);
expect(outcomes).toEqual(new Map([['call_echo', 'vetoed']]));
});

it('rejects the batch when the onExecuteTool chain completes without a result', async () => {
const tool = new TestTool('echo');
registry.register(tool);
executor.hooks.onExecuteTool.register('swallow', async () => {});

await expect(execute([toolCall('call_echo', 'echo', { text: 'hi' })])).rejects.toThrow(
'onExecuteTool hook chain for tool "echo" completed without producing a result',
);
expect(tool.calls).toEqual([]);
});

it('threads a declared delivery onto the yielded result for the agent layer to consume', async () => {
const message = {
role: 'user' as const,
Expand Down
Loading