Skip to content
Closed
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
22 changes: 21 additions & 1 deletion packages/agent-core-v2/docs/wire-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
// cross-reducers), blobs (the folding states whose blob codec offloads inline
// media to blob storage), owner (the source file declaring the class).

// Index (55 record types)
// Index (56 record types)
// config.update profile src/agent/profile/profileOps.ts
// context.append_loop_event contextMemory, turn src/agent/contextMemory/contextEvents.ts
// context.append_message contextMemory, plan, task.notificationDelivery src/agent/contextMemory/contextEvents.ts
Expand All @@ -34,6 +34,7 @@
// cron.add (none) src/features/cron/cronOps.ts
// cron.cursor (none) src/features/cron/cronOps.ts
// cron.delete (none) src/features/cron/cronOps.ts
// file.edit_snapshot.recorded (none) src/app/edit/fileEditEvents.ts
// forked (none) src/features/goal/goalOps.ts
// full_compaction.begin fullCompaction src/agent/fullCompaction/compactionOps.ts
// full_compaction.cancel fullCompaction src/agent/fullCompaction/compactionOps.ts
Expand Down Expand Up @@ -214,6 +215,24 @@ interface CronDeletePayload {
ids: string[];
}

/**
* states: (none)
* owner: src/app/edit/fileEditEvents.ts
*/
interface FileEditSnapshotRecordedPayload {
_name: 'file.edit_snapshot.recorded';
agentId: string;
turnId: number;
toolCallId: string;
path: string;
before?: object | null;
after?: {
key: string;
bytes: number;
};
truncated?: boolean;
}

/**
* states: (none)
* owner: src/features/goal/goalOps.ts
Expand Down Expand Up @@ -837,6 +856,7 @@ interface WirePayloadMap {
"cron.add": CronAddPayload;
"cron.cursor": CronCursorPayload;
"cron.delete": CronDeletePayload;
"file.edit_snapshot.recorded": FileEditSnapshotRecordedPayload;
"forked": ForkedPayload;
"full_compaction.begin": FullCompactionBeginPayload;
"full_compaction.cancel": FullCompactionCancelPayload;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { toDisposable } from '#/_base/di/lifecycle';
import { LifecycleScope } from '#/app/scopes';
import { FileEditSnapshot, FileEditSnapshotRecorded } from '#/app/edit/fileEditEvents';
import { writeFileEditSnapshotBlobs } from '#/app/edit/fileEditSnapshotBlobs';
import { IBlobStore } from '#/persistence/interface/blobStore';
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { AsyncEmitter, type Event } from '#/_base/event';
import { defineState } from '#/state/state';
Expand Down Expand Up @@ -157,6 +160,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService {
@IAgentToolResultTruncationService
private readonly resultTruncation: IAgentToolResultTruncationService,
@IAgentStateService private readonly states: IAgentStateService,
@IBlobStore private readonly blobs?: IBlobStore,
@ILogService private readonly log?: ILogService,
) {
this.states.contributeState(toolExecutorToolCallDupTypesKey);
Expand Down Expand Up @@ -301,7 +305,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService {
prepared.resolvedAccesses,
);

this.dispatchToolResult(call, finalized, options);
void this.dispatchToolResult(call, finalized, options);
this.trackToolCall(call, finalized, timedResult.durationMs, options);

return {
Expand Down Expand Up @@ -564,6 +568,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService {
approvalRule: execution?.approvalRule,
stopBatchAfterThis: normalized.stopBatchAfterThis ?? execution?.stopBatchAfterThis,
delivery: coerced.delivery,
fileSnapshot: coerced.fileSnapshot,
};
}

Expand Down Expand Up @@ -591,11 +596,11 @@ export class AgentToolExecutorService implements IAgentToolExecutorService {
});
}

private dispatchToolResult(
private async dispatchToolResult(
call: PreflightedToolCall,
result: ToolResult,
options: ToolExecutorExecuteOptions,
): void {
): Promise<void> {
void this.dispatcher.dispatch(
new ToolResultEvent({
agentId: this.scopeContext.agentId,
Expand All @@ -605,6 +610,37 @@ export class AgentToolExecutorService implements IAgentToolExecutorService {
isError: result.isError,
}),
);
const isDeduplicatedCall = this.toolCallDupTypes.has(call.toolCall.id);
if (result.fileSnapshot === undefined || isDeduplicatedCall) return;
void this.dispatcher.dispatch(
new FileEditSnapshot({
agentId: this.scopeContext.agentId,
turnId: options.turnId,
toolCallId: call.toolCall.id,
path: result.fileSnapshot.path,
before: result.fileSnapshot.before,
after: result.fileSnapshot.after,
truncated: result.fileSnapshot.truncated,
}),
);
if (this.blobs === undefined) return;
const refs = await writeFileEditSnapshotBlobs(
this.blobs,
this.scopeContext.scope(),
result.fileSnapshot.before,
result.fileSnapshot.after,
);
void this.dispatcher.dispatch(
new FileEditSnapshotRecorded({
agentId: this.scopeContext.agentId,
turnId: options.turnId,
toolCallId: call.toolCall.id,
path: result.fileSnapshot.path,
before: refs.before,
after: refs.after,
truncated: result.fileSnapshot.truncated,
}),
);
}

private dispatchToolProgress(
Expand Down Expand Up @@ -671,6 +707,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService {
effectiveResult.stopTurn === true,
stopBatchAfterThis: result.stopBatchAfterThis,
delivery: coercedResult.delivery,
fileSnapshot: coercedResult.fileSnapshot,
};
return this.resultTruncation.truncateForModel({
toolName: call.toolName,
Expand Down
6 changes: 5 additions & 1 deletion packages/agent-core-v2/src/agent/tools/edit/editTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type ToolExecution,
} from '#/tool/toolContract';
import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution';
import { buildFileSnapshot } from '#/agent/tools/fileSnapshot';

import { EditInputSchema, IEditTool, type EditInput } from './edit';
import editDescriptionTemplate from './edit.md?raw';
Expand Down Expand Up @@ -108,7 +109,10 @@ export class EditTool implements IEditTool {
return { isError: true, output: result.error };
}
const word = result.count === 1 ? 'occurrence' : 'occurrences';
return { output: `Replaced ${String(result.count)} ${word} in ${args.path}` };
return {
output: `Replaced ${String(result.count)} ${word} in ${args.path}`,
fileSnapshot: buildFileSnapshot(args.path, result.before, result.after),
};
}
}

Expand Down
11 changes: 11 additions & 0 deletions packages/agent-core-v2/src/agent/tools/fileSnapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { FileSnapshot } from '#/tool/toolContract';

const MAX_FILE_SNAPSHOT_CHARS = 256 * 1024;

export function buildFileSnapshot(path: string, before: string | null, after: string): FileSnapshot {
const size = (before?.length ?? 0) + after.length;
if (size > MAX_FILE_SNAPSHOT_CHARS) {
return { path, truncated: true };
}
return { path, before, after };
}
20 changes: 18 additions & 2 deletions packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type ToolExecution,
} from '#/tool/toolContract';
import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution';
import { buildFileSnapshot } from '#/agent/tools/fileSnapshot';
import {
resolvePathAccessPath,
type WorkspaceConfig,
Expand Down Expand Up @@ -54,7 +55,10 @@ export class WriteTool implements IWriteTool {
operation: 'write',
});
return {
accesses: ToolAccesses.writeFile(path),
accesses:
(args.mode ?? 'overwrite') === 'append'
? ToolAccesses.writeFile(path)
: ToolAccesses.readWriteFile(path),
description: `Writing ${args.path}`,
display: { kind: 'file_io', operation: 'write', path, content: args.content },
approvalRule: literalRulePattern(this.name, path),
Expand All @@ -78,14 +82,25 @@ export class WriteTool implements IWriteTool {
};
}

private async existingContent(fs: IHostFileSystem, safePath: string): Promise<string | null | undefined> {
try {
return await fs.readText(safePath, { errors: 'strict' });
Comment thread
liukx0205 marked this conversation as resolved.
} catch (error) {
const code = (unwrapErrorCause(error) as { code?: unknown } | null)?.code;
return code === 'ENOENT' ? null : undefined;
}
}

private async execution(fs: IHostFileSystem, args: WriteInput, safePath: string): Promise<ExecutableToolResult> {
const parentError = await this.ensureParentDirectory(fs, safePath);
if (parentError !== undefined) {
return { isError: true, output: parentError };
}

const mode = args.mode ?? 'overwrite';
const before = mode === 'append' ? undefined : await this.existingContent(fs, safePath);

try {
const mode = args.mode ?? 'overwrite';
if (mode === 'append') {
await fs.appendText(safePath, args.content);
} else {
Expand All @@ -94,6 +109,7 @@ export class WriteTool implements IWriteTool {
const bytesWritten = Buffer.byteLength(args.content, 'utf8');
return {
output: `${mode === 'append' ? 'Appended' : 'Wrote'} ${String(bytesWritten)} bytes to ${args.path}`,
fileSnapshot: before === undefined ? undefined : buildFileSnapshot(args.path, before, args.content),
};
} catch (error) {
const code = (unwrapErrorCause(error) as { code?: unknown } | null)?.code;
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core-v2/src/app/edit/fileEdit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export interface FileEditInput {
}

export type FileEditResult =
| { readonly ok: true; readonly count: number }
| { readonly ok: true; readonly count: number; readonly before: string; readonly after: string }
| { readonly ok: false; readonly error: string };

export interface IFileEditService {
Expand Down
59 changes: 59 additions & 0 deletions packages/agent-core-v2/src/app/edit/fileEditEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */
import { z } from 'zod';

import { AgentEvent2, registerEvent2Class } from '#/app/event/event2';

export interface FileEditSnapshotPayload {
readonly agentId: string;
readonly turnId: number;
readonly toolCallId: string;
readonly path: string;
readonly before?: string | null;
readonly after?: string;
readonly truncated?: boolean;
}

export class FileEditSnapshot extends AgentEvent2<FileEditSnapshotPayload> {
static override readonly type = 'file.edit_snapshot';
static override readonly observable = true;
}
export interface FileEditSnapshot extends FileEditSnapshotPayload {}

export interface FileBlobRef {
readonly key: string;
readonly bytes: number;
}

export interface FileEditSnapshotRecordedPayload {
readonly agentId: string;
readonly turnId: number;
readonly toolCallId: string;
readonly path: string;
readonly before?: FileBlobRef | null;
readonly after?: FileBlobRef;
readonly truncated?: boolean;
}

const fileBlobRefSchema = z.object({
key: z.string(),
bytes: z.number(),
}) satisfies z.ZodType<FileBlobRef>;

const fileEditSnapshotRecordedSchema = z.object({
agentId: z.string(),
turnId: z.number(),
toolCallId: z.string(),
path: z.string(),
before: fileBlobRefSchema.nullable().optional(),
after: fileBlobRefSchema.optional(),
truncated: z.boolean().optional(),
}) satisfies z.ZodType<FileEditSnapshotRecordedPayload>;

export class FileEditSnapshotRecorded extends AgentEvent2<FileEditSnapshotRecordedPayload> {
static override readonly type = 'file.edit_snapshot.recorded';
static override readonly durable = true;
static override readonly schema = fileEditSnapshotRecordedSchema;
}
export interface FileEditSnapshotRecorded extends FileEditSnapshotRecordedPayload {}

registerEvent2Class(FileEditSnapshotRecorded);
2 changes: 1 addition & 1 deletion packages/agent-core-v2/src/app/edit/fileEditService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export class FileEditService implements IFileEditService {
return { ok: false, error: result.error };
}
await fs.writeText(input.path, result.rawContent);
return { ok: true, count: result.count };
return { ok: true, count: result.count, before: raw, after: result.rawContent };
} catch (error) {
const code = (unwrapErrorCause(error) as { code?: unknown } | null)?.code;
if (code === 'EISDIR') {
Expand Down
32 changes: 32 additions & 0 deletions packages/agent-core-v2/src/app/edit/fileEditSnapshotBlobs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { createHash } from 'node:crypto';

import type { IBlobStore } from '#/persistence/interface/blobStore';

import type { FileBlobRef } from './fileEditEvents';

async function putContentBlob(
blobs: IBlobStore,
scope: string,
content: string,
): Promise<FileBlobRef> {
const bytes = Buffer.from(content, 'utf8');
const key = `file-edit/${createHash('sha256').update(bytes).digest('hex')}`;
if (!(await blobs.has(scope, key))) {
await blobs.put(scope, key, bytes);
}
return { key, bytes: bytes.byteLength };
}

export async function writeFileEditSnapshotBlobs(
blobs: IBlobStore,
scope: string,
before: string | null | undefined,
after: string | undefined,
): Promise<{ before?: FileBlobRef | null; after?: FileBlobRef }> {
if (after === undefined) return {};
const [beforeRef, afterRef] = await Promise.all([
typeof before === 'string' ? putContentBlob(blobs, scope, before) : null,
putContentBlob(blobs, scope, after),
]);
return { before: beforeRef, after: afterRef };
}
9 changes: 9 additions & 0 deletions packages/agent-core-v2/src/tool/toolContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ export interface ToolDelivery {
readonly message: ToolDeliveryMessage;
}

export interface FileSnapshot {
readonly path: string;
readonly before?: string | null;
readonly after?: string;
readonly truncated?: boolean;
}

export interface ExecutableToolSuccessResult {
readonly output: ExecutableToolOutput;
readonly isError?: false | undefined;
Expand All @@ -38,6 +45,7 @@ export interface ExecutableToolSuccessResult {
readonly delivery?: ToolDelivery | undefined;
readonly spill?: ToolResultSpill;
readonly spillExempt?: true;
readonly fileSnapshot?: FileSnapshot | undefined;
}

export interface ExecutableToolErrorResult {
Expand All @@ -49,6 +57,7 @@ export interface ExecutableToolErrorResult {
readonly delivery?: ToolDelivery | undefined;
readonly spill?: ToolResultSpill;
readonly spillExempt?: true;
readonly fileSnapshot?: FileSnapshot | undefined;
}

export type ExecutableToolResult = ExecutableToolSuccessResult | ExecutableToolErrorResult;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import { IEventBus } from '#/app/event/eventBus';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IFileSystemStorageService } from '#/persistence/interface/storage';
import { IBlobStore } from '#/persistence/interface/blobStore';
import { BlobStoreService } from '#/persistence/backends/node-fs/blobStoreService';
import { IAgentStateService } from '#/agent/state/agentState';
import { profileKey } from '#/agent/profile/profileOps';
import { AgentStateService } from '#/agent/state/agentStateService';
Expand Down Expand Up @@ -126,6 +128,7 @@ function createHarness(
reg.definePartialInstance(IFileSystemStorageService, {
write: async () => {},
});
reg.define(IBlobStore, BlobStoreService);
reg.define(IAgentToolResultTruncationService, ToolResultTruncationService);
registerLogServices(reg);
} else {
Expand Down
Loading
Loading