diff --git a/etc/agent.api.md b/etc/agent.api.md index 0b28ef2..bfd80a2 100644 --- a/etc/agent.api.md +++ b/etc/agent.api.md @@ -128,6 +128,13 @@ export type AgentContextValue = string | { priority?: number; }; +// @public +export type AgentMemory = { + loadRunMemory?(run: AgentRunState): Promise | readonly AgentContextValue[]; + loadProjectMemory?(run: AgentRunState): Promise | readonly AgentContextValue[]; + saveRunMemory?(run: AgentRunState): Promise | void; +}; + // @public export type AgentMiddleware = { beforeAgentRun?(context: AgentRunContext): void | Promise; @@ -162,9 +169,41 @@ export type AgentModelTrace = { createdAt: number; }; +// @public +export class AgentObserver { + // Warning: (ae-forgotten-export) The symbol "AgentObserverOptions" needs to be exported by the entry point index.d.ts + constructor(options: AgentObserverOptions); + assistantDelta(run: AgentRunState, delta: string): void; + assistantStage(run: AgentRunState): void; + changeSetApplied(run: AgentRunState, changeSet: ChangeSet, checkpointPath?: string): void; + changeSetRollbackCompleted(run: AgentRunState, changeSet: ChangeSet): void; + changeSetRollbackStarted(run: AgentRunState, changeSet: ChangeSet): void; + contextBuilt(run: AgentRunState, tokenEstimate: number): void; + get eventBus(): EventBus; + metadata(run: AgentRunState): Record; + modelCompleted(_run: AgentRunState, _response: AgentModelResponse): void; + runCompleted(run: AgentRunState): void; + runFailed(run: AgentRunState, error: unknown): void; + runStarted(run: AgentRunState): void; + toolCompleted(run: AgentRunState, call: AgentToolCall, result: ToolResult): void; + toolStarted(run: AgentRunState, call: AgentToolCall): void; + toolStreamed(run: AgentRunState, call: AgentToolCall, stream: ToolStreamChunk): void; + verificationCompleted(run: AgentRunState, results: readonly VerificationResult[]): void; + verificationStarted(run: AgentRunState, commands: readonly string[]): void; +} + // @public export type AgentOptions = { config: AgentRuntimeConfig | AgentConfig; + model?: ModelRuntime; + tools?: ToolRuntime; + context?: ContextManager; + workspace?: WorkspaceService; + memory?: AgentMemory; + policy?: RuntimePolicy; + changes?: ChangeRuntime; + verification?: VerificationPipeline; + observer?: AgentObserver; llm?: BaseLLMClient; toolRegistry?: ToolRegistry; toolRunner?: ToolRunner; @@ -234,6 +273,43 @@ export type AgentRunResult = { error?: unknown; }; +// @public +export class AgentRunState { + constructor(options: AgentRunStateOptions); + canContinue(maxIterations: number): boolean; + readonly changes: ChangeSet[]; + checkpointPath?: string; + complete(content?: string): void; + content: string; + readonly context: AgentRunContext; + error?: unknown; + fail(error?: unknown): void; + readonly input: AgentRunInput; + // Warning: (ae-forgotten-export) The symbol "RunInternalOptions" needs to be exported by the entry point index.d.ts + readonly internalOptions: RunInternalOptions; + iteration: number; + readonly messages: LLMMessage[]; + nextIteration(): number; + readonly runId: string; + readonly sessionId: string; + stopReason: AgentStopReason; + readonly task: TaskRun; + readonly toolResults: AgentToolResult[]; + toResult(): AgentRunResult; + readonly traceId: string; + usage?: LLMUsage; + readonly verification: VerificationResult[]; + workspaceProfile?: WorkspaceProfile; +} + +// @public +export type AgentRunStateOptions = { + agent: AgentRunContext["agent"]; + config: AgentRuntimeConfig; + input: AgentRunInput; + internalOptions?: RunInternalOptions; +}; + // @public export type AgentRuntimeConfig = { llm?: AgentConfig["llm"]; @@ -301,6 +377,23 @@ export type ChangedFile = { // @public (undocumented) export type ChangedFileStatus = "created" | "modified" | "deleted"; +// @public +export class ChangeRuntime { + constructor(options: ChangeRuntimeOptions); + checkpoint(run: AgentRunState): Promise; + prepare(run: AgentRunState): ToolFileWriter; + rollback(run: AgentRunState): Promise; +} + +// @public +export type ChangeRuntimeOptions = { + config: AgentRuntimeConfig; + workspace: WorkspaceService; + policy: RuntimePolicy; + observer: AgentObserver; + checkpointStore?: CheckpointStore; +}; + // @public (undocumented) export type ChangeSet = { id: string; @@ -335,6 +428,9 @@ export type CheckpointStore = { save(changeSet: ChangeSet): Promise; }; +// @public +export const CodingAgent: typeof Agent; + // @public export class CommandPolicy implements CommandPolicyLike { constructor(options?: CommandPolicyOptions); @@ -381,6 +477,29 @@ export type CommandPolicyOptions = { rules?: readonly CommandPolicyRule[]; }; +// @public +export class ContextManager { + constructor(options: ContextManagerOptions); + appendAssistantResponse(run: AgentRunState, response: AgentModelResponse): void; + appendRepairPrompt(run: AgentRunState, content: string): void; + appendToolResults(run: AgentRunState, toolResults: readonly AgentToolResult[]): void; + buildModelRequest(run: AgentRunState): readonly LLMMessage[]; + prepare(run: AgentRunState): Promise; + save(run: AgentRunState): Promise; +} + +// @public +export type ContextManagerOptions = { + config: AgentRuntimeConfig; + workspace: WorkspaceService; + memory: AgentMemory; + observer: AgentObserver; + contextProviders?: readonly AgentContextProvider[]; +}; + +// @public +export function createAgentObserver(options: AgentObserverOptions): AgentObserver; + // @public export function createAgentRuntime(options: AgentOptions): Agent; @@ -393,12 +512,36 @@ export function createAgentRuntimeFromConfig(options?: CreateAgentRuntimeFromCon // @public (undocumented) export type CreateAgentRuntimeFromConfigOptions = LoadAgentConfigOptions & AgentRuntimeInjectionOptions; +// @public +export function createChangeRuntime(options: ChangeRuntimeOptions): ChangeRuntime; + // @public export function createCommandPolicy(options?: CommandPolicyOptions): CommandPolicy; +// @public +export function createContextManager(options: ContextManagerOptions): ContextManager; + // @public export function createDefaultToolRegistry(): ToolRegistry; +// @public +export function createModelRuntime(options: ModelRuntimeOptions): ModelRuntime; + +// @public +export function createNoopMemory(): AgentMemory; + +// @public +export function createRuntimePolicy(options: RuntimePolicyOptions): RuntimePolicy; + +// @public +export function createToolRuntime(options: ToolRuntimeOptions): ToolRuntime; + +// @public +export function createVerificationPipeline(options: VerificationPipelineOptions): VerificationPipeline; + +// @public +export function createWorkspaceService(options: WorkspaceServiceOptions): WorkspaceService; + // @public (undocumented) export const DEFAULT_WEB_FETCH_MAX_LENGTH = 20000; @@ -551,6 +694,20 @@ export const MAX_WEB_FETCH_MAX_LENGTH = 200000; // @public (undocumented) export const MAX_WEB_FETCH_TIMEOUT_MS = 60000; +// @public +export class ModelRuntime { + constructor(options: ModelRuntimeOptions); + generate(input: Omit, run: AgentRunState): Promise; +} + +// @public +export type ModelRuntimeOptions = { + config: AgentRuntimeConfig; + llm?: BaseLLMClient; + middleware: AgentMiddlewarePipeline; + observer: AgentObserver; +}; + // @public export function okToolResult(message: string, data: TData, display?: ToolSuccessResult["display"]): ToolSuccessResult; @@ -607,6 +764,20 @@ export const RuntimeConfigSchema: z.ZodObject<{ rollbackOnFailure: z.ZodBoolean; }, z.core.$strip>; +// @public +export class RuntimePolicy { + constructor(options: RuntimePolicyOptions); + readonly commandPolicy: CommandPolicyLike; + toolPermissions(runPermissions?: ToolPermissions): ToolPermissions; +} + +// @public +export type RuntimePolicyOptions = { + config: AgentRuntimeConfig["permissions"]; + permissions?: ToolPermissions; + commandPolicy?: CommandPolicyLike; +}; + // @public (undocumented) export type SafeWorkspacePath = { absolutePath: string; @@ -736,13 +907,18 @@ export type ToolResult = ToolSuccessResult | ToolErrorRe // @public export type ToolResultDisplay = { + kind?: ToolResultDisplayKind; title?: string; + target?: string; summary?: string; preview?: string; - stats?: Record; + stats?: Record; truncated?: boolean; }; +// @public (undocumented) +export type ToolResultDisplayKind = "command" | "file" | "edit" | "search" | "list" | "network" | "text" | "json"; + // @public export class ToolRunner { constructor(registry: ToolRegistry, options?: ToolRunnerOptions); @@ -800,10 +976,43 @@ export type ToolRunOptions = { metadata?: Record; }; +// @public +export class ToolRuntime { + constructor(options: ToolRuntimeOptions); + execute(run: AgentRunState, toolCalls: readonly AgentToolCall[]): Promise; + // Warning: (ae-forgotten-export) The symbol "LLMTool" needs to be exported by the entry point index.d.ts + schemas(): LLMTool[]; +} + +// @public +export type ToolRuntimeOptions = { + config: AgentRuntimeConfig; + workspace: WorkspaceService; + policy: RuntimePolicy; + changes: ChangeRuntime; + observer: AgentObserver; + middleware: AgentMiddlewarePipeline; + toolRegistry?: ToolRegistry; + toolRunner?: ToolRunner; +}; + // @public export type ToolStreamChunk = { - type: "stdout" | "stderr" | "data"; + type: "stdout" | "stderr" | "log"; content: string; + level?: "debug" | "info" | "warn" | "error"; + metadata?: Record; +} | { + type: "data"; + data?: unknown; + content?: string; + metadata?: Record; +} | { + type: "progress"; + label?: string; + current?: number; + total?: number; + percent?: number; metadata?: Record; }; @@ -864,6 +1073,25 @@ export type VerificationOptions = { signal?: AbortSignal; }; +// @public +export class VerificationPipeline { + constructor(options: VerificationPipelineOptions); + verifyAndRepair(run: AgentRunState, maxIterations: number): Promise; +} + +// @public +export type VerificationPipelineOptions = { + config: AgentRuntimeConfig; + workspace: WorkspaceService; + policy: RuntimePolicy; + tools: ToolRuntime; + model: ModelRuntime; + context: ContextManager; + changes: ChangeRuntime; + observer: AgentObserver; + verifier?: Verifier; +}; + // @public (undocumented) export type VerificationResult = { command: string; @@ -904,6 +1132,19 @@ export class WorkspaceScanner { scan(workspaceRoot: string, signal?: AbortSignal): Promise; } +// @public +export class WorkspaceService { + constructor(options: WorkspaceServiceOptions); + prepare(run: AgentRunState): Promise; + get root(): string; +} + +// @public +export type WorkspaceServiceOptions = { + workspaceRoot: string; + scanner?: WorkspaceScanner; +}; + // Warning: (ae-forgotten-export) The symbol "writeFileParameters" needs to be exported by the entry point index.d.ts // // @public @@ -914,8 +1155,9 @@ export const writeFileTool: Tool; - private readonly llm: BaseLLMClient; - private readonly toolRegistry: ToolRegistry; - private readonly toolRunner: ToolRunner; - private readonly usesToolRunnerEventAdapter: boolean; + // Handles model requests, streaming, retries, and response normalization. + private readonly model: ModelRuntime; + + // Manages tool schemas, tool execution, permissions, and tool results. + private readonly tools: ToolRuntime; + + // Builds and updates the model-visible context for each run. + private readonly context: ContextManager; + + // Prepares and inspects the current workspace. + private readonly workspace: WorkspaceService; + + // Loads and stores agent, project, or user memory. + private readonly memory: AgentMemory; + + // Centralizes runtime permissions, command policy, and safety decisions. + private readonly policy: RuntimePolicy; + + // Tracks file changes, checkpoints, diffs, and rollback behavior. + private readonly changes: ChangeRuntime; + + // Runs verification and repair flows after agent execution. + private readonly verification: VerificationPipeline; + + // Publishes lifecycle events for UI, streaming, and observers. + private readonly observer: AgentObserver; + + // Mutable list of middleware registered on this agent instance. private readonly middleware: AgentMiddleware[]; + + // Executes middleware hooks around runs, model calls, and tool calls. private readonly middlewarePipeline: AgentMiddlewarePipeline; - private readonly contextProviders: AgentContextProvider[]; - private readonly permissions: ToolPermissions; - private readonly traceStore?: AgentOptions["traceStore"]; - private readonly checkpointStore?: AgentOptions["checkpointStore"]; - private readonly workspaceScanner: WorkspaceScanner; - private readonly verifier: Verifier; - private readonly commandPolicy: CommandPolicyLike; - private readonly runOptionsByTraceId = new Map(); - private readonly pendingToolRunnerTerminalEvents = new Map< - string, - Exclude< - ToolRunnerEvent, - {type: "runner.tool.started"} | {type: "runner.tool.streamed"} - > - >(); - /** Creates an agent runtime from explicit dependencies and runtime configuration. */ + /** + * Creates an agent runtime from explicit dependencies and runtime configuration. + * + * Missing dependencies are initialized with the default modular runtime + * implementations, while injected dependencies can override any subsystem. + */ constructor(options: AgentOptions) { this.config = normalizeConfig(options.config); - this.llm = - options.llm ?? - (this.config.llm ? new LLMClient(this.config.llm) : missingLLMClient()); - this.toolRegistry = options.toolRegistry ?? createDefaultToolRegistry(); this.eventBus = options.eventBus ?? new EventBus(); - this.usesToolRunnerEventAdapter = !options.toolRunner; - this.toolRunner = - options.toolRunner ?? - new ToolRunner(this.toolRegistry, { - onEvent: (event) => { - this.emitToolRunnerEvent(event); - }, - }); this.middleware = [...(options.middleware ?? [])]; this.middlewarePipeline = new AgentMiddlewarePipeline(this.middleware); - this.contextProviders = [...(options.contextProviders ?? [])]; - this.permissions = mergePermissions(this.config.permissions, options.permissions); - this.traceStore = options.traceStore; - this.checkpointStore = options.checkpointStore; - this.workspaceScanner = options.workspaceScanner ?? new WorkspaceScanner(); - this.commandPolicy = options.commandPolicy ?? createCommandPolicy(); - this.verifier = options.verifier ?? new Verifier(this.commandPolicy); + + this.observer = options.observer ?? createAgentObserver({eventBus: this.eventBus}); + this.policy = + options.policy ?? + createRuntimePolicy({ + config: this.config.permissions, + permissions: options.permissions, + commandPolicy: options.commandPolicy, + }); + this.workspace = + options.workspace ?? + createWorkspaceService({ + workspaceRoot: this.config.runtime.workspaceDir, + scanner: options.workspaceScanner, + }); + this.memory = options.memory ?? createNoopMemory(); + this.changes = + options.changes ?? + createChangeRuntime({ + config: this.config, + workspace: this.workspace, + policy: this.policy, + observer: this.observer, + checkpointStore: options.checkpointStore, + }); + this.tools = + options.tools ?? + createToolRuntime({ + config: this.config, + workspace: this.workspace, + policy: this.policy, + changes: this.changes, + observer: this.observer, + middleware: this.middlewarePipeline, + toolRegistry: options.toolRegistry, + toolRunner: options.toolRunner, + }); + this.model = + options.model ?? + createModelRuntime({ + config: this.config, + llm: options.llm, + middleware: this.middlewarePipeline, + observer: this.observer, + }); + this.context = + options.context ?? + createContextManager({ + config: this.config, + workspace: this.workspace, + memory: this.memory, + observer: this.observer, + contextProviders: options.contextProviders, + }); + this.verification = + options.verification ?? + createVerificationPipeline({ + config: this.config, + workspace: this.workspace, + policy: this.policy, + tools: this.tools, + model: this.model, + context: this.context, + changes: this.changes, + observer: this.observer, + verifier: options.verifier, + }); } - /** Registers middleware and returns a disposer for removing it. */ + /** + * Registers middleware and returns a disposer that removes it. + */ use(middleware: AgentMiddleware): () => void { this.middleware.push(middleware); @@ -127,12 +177,16 @@ export class Agent { }; } - /** Runs the agent to completion and returns the final transcript and metadata. */ + /** + * Runs the agent to completion and returns the final result. + */ run(input: AgentRunInput): Promise { return this.runInternal(input, {}); } - /** Streams lifecycle events while the same underlying run executes. */ + /** + * Runs the agent and yields lifecycle events as they are produced. + */ async *stream(input: AgentRunInput): AsyncIterable { const queue: StreamQueueItem[] = []; const waiters: Array<() => void> = []; @@ -175,991 +229,114 @@ export class Agent { } } - /** Runs the full agent workflow, including model/tool loop, verification, and tracing. */ + /** + * Executes the full agent workflow for a single run. + * + * The workflow prepares runtime state, builds model context, executes + * model/tool iterations, verifies the result, optionally rolls back changes, + * and returns the final run result. + */ private async runInternal( input: AgentRunInput, options: RunInternalOptions, ): Promise { - const runId = options.runId ?? randomUUID(); - const sessionId = options.sessionId ?? runId; - const traceId = options.traceId ?? randomUUID(); - const maxIterations = - input.maxIterations ?? this.config.runtime.maxIterations ?? DEFAULT_MAX_ITERATIONS; - const context = this.createRunContext({ + const run = new AgentRunState({ + agent: this, + config: this.config, input, - runId, - sessionId, - traceId, - }); - const messages: LLMMessage[] = []; - const toolResults: AgentToolResult[] = []; - const changes: ChangeSet[] = []; - const verification: VerificationResult[] = []; - const task = createTaskRun(runId); - const traceStore = - this.config.trace?.enabled === false - ? this.traceStore - : (this.traceStore ?? - new JsonTraceStore( - this.config.trace?.directory ?? this.config.runtime.workspaceDir, - runId, - )); - const checkpointStore = - this.checkpointStore ?? - new JsonCheckpointStore( - this.config.trace?.directory ?? this.config.runtime.workspaceDir, - runId, - ); - const changeTracker = new ChangeTracker({ - runId, - workspaceRoot: this.config.runtime.workspaceDir, - checkpointStore, + internalOptions: options, }); - let checkpointPath: string | undefined; - let workspaceProfile: WorkspaceProfile | undefined; - let content = ""; - let stopReason: AgentStopReason = "completed"; - let usage: LLMUsage | undefined; + const maxIterations = + input.maxIterations ?? this.config.runtime.maxIterations ?? DEFAULT_MAX_ITERATIONS; - this.runOptionsByTraceId.set(traceId, options); + registerToolRuntimeRun(run); try { - context.traceStore = traceStore; - context.fileWriter = changeTracker; - await traceStore?.start({ - runId, - sessionId, - traceId, - prompt: input.prompt, - createdAt: Date.now(), - updatedAt: Date.now(), - events: [], - modelCalls: [], - toolCalls: [], - changeSets: changes, - verificationResults: verification, - }); - this.emitRunStarted(input, sessionId, traceId, options); - this.emitTaskStarted(task, input, sessionId, traceId, options); - await this.middlewarePipeline.beforeAgentRun(context); - - workspaceProfile = await this.workspaceScanner.scan( - this.config.runtime.workspaceDir, - input.signal, - ); - context.workspaceProfile = workspaceProfile; - context.input.context = [ - ...(context.input.context ?? []), - { - title: "Workspace Profile", - priority: 100, - content: JSON.stringify(workspaceProfile, null, 2), - }, - ]; - await traceStore?.update((trace) => { - trace.workspaceProfile = workspaceProfile; - }); - - await this.addInitialMessages(messages, context, options); - const tools = buildLLMTools(this.toolRegistry); - - // The core loop alternates model responses and tool results until the - // model stops requesting tools or a runtime guard stops the run. - for (let iteration = 1; iteration <= maxIterations; iteration += 1) { - if (input.signal?.aborted) { - stopReason = "aborted"; - break; - } - - context.iteration = iteration; - this.emitAssistantStage(context, iteration, options); - - const response = await this.generateModelResponse({ - context, - iteration, - messages, - tools, - options, - }); - usage = mergeUsage(usage, response.usage); - content = response.content || content; - - messages.push({ - role: "assistant", - content: response.content, - toolCalls: response.toolCalls, - }); + this.observer.runStarted(run); + await this.middlewarePipeline.beforeAgentRun(run.context); + + run.task.status = "planning"; + await this.workspace.prepare(run); + this.changes.prepare(run); + await this.context.prepare(run); + + run.task.status = "executing"; + while (run.canContinue(maxIterations)) { + run.nextIteration(); + this.observer.assistantStage(run); + + const response = await this.model.generate( + { + messages: this.context.buildModelRequest(run), + tools: this.tools.schemas(), + }, + run, + ); + this.context.appendAssistantResponse(run, response); if (!response.toolCalls.length) { - stopReason = "completed"; + run.complete(response.content); break; } - for (const toolCall of response.toolCalls) { - const toolResult = await this.runTool( - {...toolCall, iteration}, - context, - options, - ); - toolResults.push(toolResult); - messages.push({ - role: "tool", - toolCallId: toolResult.call.id, - name: toolResult.call.name, - content: stringifyToolResult(toolResult.result), - }); - } - - const checkpoint = await changeTracker.checkpoint(); - if (checkpoint.changeSet) { - changes.push(checkpoint.changeSet); - checkpointPath = checkpoint.path ?? checkpointPath; - this.emitChangeSetApplied( - checkpoint.changeSet, - checkpoint.path, - context, - options, - ); - await traceStore?.update((trace) => { - trace.changeSets = changes; - }); - } - - if (iteration === maxIterations) { - stopReason = "max_iterations"; - } - } - - const verificationEnabled = - input.verification?.enabled ?? - this.config.verification?.enabled ?? - input.mode !== "ask"; - if (verificationEnabled && workspaceProfile) { - task.status = "verifying"; - const selectedCommands = this.verifier.selectCommands( - workspaceProfile, - input.verification?.commands?.length - ? input.verification.commands - : this.config.verification?.commands, + const toolResults = await this.tools.execute( + run, + response.toolCalls.map((toolCall) => ({ + ...toolCall, + iteration: run.iteration, + })), ); - this.emitVerificationStarted( - selectedCommands, - input, - sessionId, - traceId, - options, - ); - verification.push( - ...(await this.verifier.verify( - this.config.runtime.workspaceDir, - workspaceProfile, - { - commands: input.verification?.commands?.length - ? input.verification.commands - : this.config.verification?.commands, - signal: input.signal, - }, - )), - ); - this.emitVerificationCompleted(verification, input, sessionId, traceId, options); - await traceStore?.update((trace) => { - trace.verificationResults = verification; - }); - - let failedVerification = verification.find((result) => !result.passed); - const maxRepairAttempts = - input.maxRepairAttempts ?? this.config.runtime.maxRepairAttempts; - for ( - let repairAttempt = 1; - failedVerification && repairAttempt <= maxRepairAttempts; - repairAttempt += 1 - ) { - if (context.iteration >= maxIterations || input.signal?.aborted) { - break; - } - - task.status = "repairing"; - messages.push({ - role: "user", - content: buildRepairPrompt(failedVerification, repairAttempt), - }); - - context.iteration += 1; - this.emitAssistantStage(context, context.iteration, options); - const repairResponse = await this.generateModelResponse({ - context, - iteration: context.iteration, - messages, - tools, - options, - }); - usage = mergeUsage(usage, repairResponse.usage); - content = repairResponse.content || content; - messages.push({ - role: "assistant", - content: repairResponse.content, - toolCalls: repairResponse.toolCalls, - }); - - for (const toolCall of repairResponse.toolCalls) { - const toolResult = await this.runTool( - {...toolCall, iteration: context.iteration}, - context, - options, - ); - toolResults.push(toolResult); - messages.push({ - role: "tool", - toolCallId: toolResult.call.id, - name: toolResult.call.name, - content: stringifyToolResult(toolResult.result), - }); - } - - const checkpoint = await changeTracker.checkpoint(); - if (checkpoint.changeSet) { - changes.push(checkpoint.changeSet); - checkpointPath = checkpoint.path ?? checkpointPath; - this.emitChangeSetApplied( - checkpoint.changeSet, - checkpoint.path, - context, - options, - ); - } + this.context.appendToolResults(run, toolResults); + await this.changes.checkpoint(run); + } - const repairVerification = await this.verifier.verify( - this.config.runtime.workspaceDir, - workspaceProfile, - { - commands: input.verification?.commands?.length - ? input.verification.commands - : this.config.verification?.commands, - signal: input.signal, - }, - ); - verification.push(...repairVerification); - this.emitVerificationCompleted( - repairVerification, - input, - sessionId, - traceId, - options, - ); - await traceStore?.update((trace) => { - trace.changeSets = changes; - trace.verificationResults = verification; - }); - failedVerification = repairVerification.find((result) => !result.passed); - } + if (input.signal?.aborted) { + run.stopReason = "aborted"; + } else if (run.iteration >= maxIterations && run.stopReason === "completed") { + run.stopReason = "max_iterations"; + } - if (failedVerification) { - stopReason = "error"; - content = - `${content}\n\nVerification failed: ${failedVerification.command}`.trim(); - } else { - stopReason = "completed"; - } + if (run.stopReason !== "aborted") { + await this.verification.verifyAndRepair(run, maxIterations); } if ( - stopReason !== "completed" && - (input.rollbackOnFailure ?? this.config.runtime.rollbackOnFailure) && - changes.length > 0 + run.stopReason !== "completed" && + (input.rollbackOnFailure ?? this.config.runtime.rollbackOnFailure) ) { - task.status = "rolled_back"; - for (const changeSet of [...changes].reverse()) { - this.emitChangeSetRollbackStarted(changeSet, context, options); - await changeTracker.rollback(changeSet); - this.emitChangeSetRollbackCompleted(changeSet, context, options); - } + await this.changes.rollback(run); } else { - task.status = stopReason === "completed" ? "completed" : "failed"; + run.task.status = run.stopReason === "completed" ? "completed" : "failed"; } - task.updatedAt = Date.now(); + run.task.updatedAt = Date.now(); + await this.context.save(run); const result = await this.middlewarePipeline.afterAgentRun( - { - runId, - sessionId, - traceId, - content, - messages, - toolResults, - usage, - iterations: context.iteration, - stopReason, - task, - changes, - verification, - workspaceProfile, - tracePath: traceStore?.tracePath, - checkpointPath, - }, - context, + run.toResult(), + run.context, ); - - this.emitRunStopped(input, runId, sessionId, traceId, stopReason, options); - await traceStore?.update((trace) => { - const {messages: resultMessages, ...serializableResult} = result; - trace.events = this.eventsForTrace(traceId); - trace.finalResult = { - ...serializableResult, - messageCount: resultMessages.length, - }; - }); - this.emitTracePersisted(traceStore?.tracePath, input, sessionId, traceId, options); + this.observer.runCompleted(run); return result; } catch (error) { - stopReason = input.signal?.aborted ? "aborted" : "error"; - task.status = "failed"; - task.updatedAt = Date.now(); - this.emitRunFailed(input, sessionId, traceId, stopReason, error, options); - await traceStore?.update((trace) => { - trace.events = this.eventsForTrace(traceId); - trace.error = error instanceof Error ? error.message : "Agent run failed."; - }); - - return { - runId, - sessionId, - traceId, - content, - messages, - toolResults, - usage, - iterations: context.iteration, - stopReason, - task, - changes, - verification, - workspaceProfile, - tracePath: traceStore?.tracePath, - checkpointPath, - error, - }; + run.fail(error); + run.task.status = "failed"; + run.task.updatedAt = Date.now(); + this.observer.runFailed(run, error); + return run.toResult(); } finally { - this.runOptionsByTraceId.delete(traceId); - this.deletePendingToolRunnerEvents(traceId); - } - } - - /** Creates the mutable per-run context passed to middleware and context providers. */ - private createRunContext(input: { - input: AgentRunInput; - runId: string; - sessionId: string; - traceId: string; - }): AgentRunContext { - return { - agent: this, - config: this.config, - input: input.input, - iteration: 0, - runId: input.runId, - sessionId: input.sessionId, - signal: input.input.signal, - traceId: input.traceId, - }; - } - - /** Builds and appends the initial system, historical, and user messages. */ - private async addInitialMessages( - messages: LLMMessage[], - context: AgentRunContext, - options: RunInternalOptions, - ): Promise { - const contextText = await buildRuntimeContext({ - context, - contextProviders: this.contextProviders, - eventBus: this.eventBus, - options, - }); - const systemPrompt = buildSystemPrompt(context, contextText); - - messages.push({role: "system", content: systemPrompt}); - messages.push(...(context.input.messages ?? [])); - messages.push({role: "user", content: context.input.prompt}); - } - - /** Runs model middleware, calls the LLM, records trace data, and returns model output. */ - private async generateModelResponse(input: { - context: AgentRunContext; - iteration: number; - messages: LLMMessage[]; - tools: ReturnType; - options: RunInternalOptions; - }): Promise { - const request = await this.middlewarePipeline.beforeModel( - { - messages: input.messages, - tools: input.tools, - timeoutMs: this.config.llm?.timeoutMs, - maxRetries: this.config.llm?.maxRetries, - iteration: input.iteration, - runId: input.context.runId, - }, - input.context, - ); - const rawResponse = await this.generateStreamingModelResponse( - request, - input.context, - input.options, - ); - await input.context.traceStore?.update((trace) => { - trace.modelCalls.push({ - request: createTraceModelRequest(request), - response: { - ...createTraceModelResponse(rawResponse), - iteration: input.iteration, - runId: input.context.runId, - }, - createdAt: Date.now(), - }); - }); - - return this.middlewarePipeline.afterModel( - { - ...rawResponse, - iteration: input.iteration, - runId: input.context.runId, - }, - input.context, - ); - } - - /** Streams model output when supported and falls back to generate() when needed. */ - private async generateStreamingModelResponse( - request: Parameters[0], - context: AgentRunContext, - options: RunInternalOptions, - ): Promise { - let streamedContent = ""; - let emittedContent = false; - let finalResponse: AgentModelResponse | undefined; - - try { - for await (const chunk of this.llm.stream(request)) { - if (chunk.type === "content_delta") { - streamedContent += chunk.content; - emittedContent = true; - emitAgentEvent( - this.eventBus, - { - type: "conversation.assistant_delta", - messageId: context.runId, - delta: chunk.content, - stage: "thinking", - metadata: createEventMetadata( - context.input, - context.sessionId, - context.traceId, - ), - }, - options, - ); - continue; - } - - if (chunk.type === "done") { - finalResponse = { - ...chunk.response, - content: chunk.response.content || streamedContent, - iteration: context.iteration, - runId: context.runId, - }; - } - } - } catch (error) { - if (emittedContent) { - throw error; - } - - const response = await this.llm.generate(request); - return { - ...response, - iteration: context.iteration, - runId: context.runId, - }; - } - - if (!finalResponse) { - const response = await this.llm.generate(request); - return { - ...response, - iteration: context.iteration, - runId: context.runId, - }; + unregisterToolRuntimeRun(run); } - - return finalResponse; - } - - /** Executes one model-requested tool call through middleware and ToolRunner. */ - private async runTool( - call: AgentToolCall, - context: AgentRunContext, - options: RunInternalOptions, - ): Promise { - const toolCall = await this.middlewarePipeline.beforeTool(call, context); - const metadata = createEventMetadata( - context.input, - context.sessionId, - context.traceId, - ); - - if (!this.usesToolRunnerEventAdapter) { - emitAgentEvent( - this.eventBus, - { - type: "tool.call_started", - id: toolCall.id, - name: toolCall.name, - input: toolCall.arguments, - status: "running", - metadata, - }, - options, - ); - } - - // ToolRunner owns schema validation and tool-level error normalization. - const result = await this.toolRunner.run( - toolCall.name, - toolCall.arguments, - createToolContext({ - workspaceRoot: this.config.runtime.workspaceDir, - signal: context.input.signal, - basePermissions: this.permissions, - runPermissions: context.input.permissions, - fileWriter: context.fileWriter, - workspaceProfile: context.workspaceProfile, - commandPolicy: this.commandPolicy, - emitStream: this.usesToolRunnerEventAdapter - ? undefined - : (stream) => this.emitToolCallStream(toolCall, metadata, options, stream), - }), - { - callId: toolCall.id, - metadata, - }, - ); - const toolResult = await this.middlewarePipeline.afterTool( - {call: toolCall, result}, - context, - ); - await context.traceStore?.update((trace) => { - trace.toolCalls.push(toolResult); - }); - - if (this.usesToolRunnerEventAdapter) { - this.emitFinalToolRunnerEvent(toolCall.id, metadata, toolResult.result); - return toolResult; - } - - if (toolResult.result.ok) { - emitAgentEvent( - this.eventBus, - { - type: "tool.call_completed", - id: toolCall.id, - name: toolCall.name, - result: toolResult.result, - output: toolResult.result.data, - summary: toolResult.result.message, - display: toolResult.result.display, - metadata, - }, - options, - ); - } else { - emitAgentEvent( - this.eventBus, - { - type: "tool.call_failed", - id: toolCall.id, - name: toolCall.name, - result: toolResult.result, - error: toolResult.result.message, - code: toolResult.result.code, - data: toolResult.result.data, - display: toolResult.result.display, - metadata, - }, - options, - ); - } - - return toolResult; - } - - /** Handles default ToolRunner events without exposing raw terminal results. - * - * Live runner events are safe to publish immediately. Terminal runner events - * are cached for their timing/metadata only; the public terminal Agent event - * is emitted after afterTool middleware has produced the final ToolResult. - */ - private emitToolRunnerEvent(event: ToolRunnerEvent): void { - const traceId = - typeof event.metadata?.traceId === "string" ? event.metadata.traceId : undefined; - if (event.type !== "runner.tool.started" && event.type !== "runner.tool.streamed") { - if (traceId) { - this.pendingToolRunnerTerminalEvents.set( - this.toolRunnerEventKey(traceId, event.callId), - event, - ); - return; - } - - return; - } - - const options = traceId ? this.runOptionsByTraceId.get(traceId) : undefined; - - emitRunnerLiveEventAsAgentEvent({ - eventBus: this.eventBus, - event, - options: options ?? {}, - }); - } - - /** Emits stream chunks for externally supplied ToolRunner instances. */ - private emitToolCallStream( - toolCall: AgentToolCall, - metadata: Record, - options: RunInternalOptions, - stream: ToolStreamChunk, - ): void { - emitAgentEvent( - this.eventBus, - { - type: "tool.call_stream", - id: toolCall.id, - name: toolCall.name, - stream, - metadata, - }, - options, - ); - } - - /** Emits the final Agent tool event after afterTool middleware has finalized the result. */ - private emitFinalToolRunnerEvent( - callId: string, - metadata: Record, - result: AgentToolResult["result"], - ): void { - const traceId = typeof metadata.traceId === "string" ? metadata.traceId : undefined; - const terminalEvent = traceId - ? this.pendingToolRunnerTerminalEvents.get(this.toolRunnerEventKey(traceId, callId)) - : undefined; - const options = traceId ? this.runOptionsByTraceId.get(traceId) : undefined; - - if (traceId) { - this.pendingToolRunnerTerminalEvents.delete( - this.toolRunnerEventKey(traceId, callId), - ); - } - - if (!terminalEvent) { - return; - } - - emitFinalToolResultAsAgentEvent({ - eventBus: this.eventBus, - runnerEvent: terminalEvent, - result, - options: options ?? {}, - }); - } - - /** Creates a stable key for pending runner terminal events within one trace. */ - private toolRunnerEventKey(traceId: string, callId: string): string { - return `${traceId}:${callId}`; - } - - /** Removes any pending terminal runner events left behind by an ending run. */ - private deletePendingToolRunnerEvents(traceId: string): void { - const prefix = `${traceId}:`; - - for (const key of this.pendingToolRunnerTerminalEvents.keys()) { - if (key.startsWith(prefix)) { - this.pendingToolRunnerTerminalEvents.delete(key); - } - } - } - - /** Emits the standard run-start lifecycle events. */ - private emitRunStarted( - input: AgentRunInput, - sessionId: string, - traceId: string, - options: RunInternalOptions, - ): void { - const metadata = createEventMetadata(input, sessionId, traceId); - - emitAgentEvent( - this.eventBus, - {type: "runtime.session_started", sessionId, metadata}, - options, - ); - emitAgentEvent( - this.eventBus, - {type: "runtime.status_changed", status: "running", metadata}, - options, - ); - emitAgentEvent( - this.eventBus, - {type: "conversation.user_message", content: input.prompt, metadata}, - options, - ); - } - - /** Emits the standard run-stop lifecycle events for completed or non-error stops. */ - private emitRunStopped( - input: AgentRunInput, - runId: string, - sessionId: string, - traceId: string, - stopReason: AgentStopReason, - options: RunInternalOptions, - ): void { - const metadata = createEventMetadata(input, sessionId, traceId); - - emitAgentEvent( - this.eventBus, - {type: "conversation.assistant_done", messageId: runId, metadata}, - options, - ); - emitAgentEvent( - this.eventBus, - { - type: "runtime.status_changed", - status: stopReason === "completed" ? "complete" : "waiting", - detail: stopReason, - metadata, - }, - options, - ); - emitAgentEvent( - this.eventBus, - {type: "runtime.session_stopped", sessionId, metadata}, - options, - ); - } - - /** Emits error lifecycle events when the agent run fails unexpectedly. */ - private emitRunFailed( - input: AgentRunInput, - sessionId: string, - traceId: string, - stopReason: AgentStopReason, - error: unknown, - options: RunInternalOptions, - ): void { - const metadata = createEventMetadata(input, sessionId, traceId); - - emitAgentEvent( - this.eventBus, - { - type: "runtime.error", - message: error instanceof Error ? error.message : "Agent run failed.", - detail: error, - metadata, - }, - options, - ); - emitAgentEvent( - this.eventBus, - { - type: "runtime.status_changed", - status: "error", - detail: stopReason, - metadata, - }, - options, - ); - emitAgentEvent( - this.eventBus, - {type: "runtime.session_stopped", sessionId, metadata}, - options, - ); - } - - /** Emits the assistant stage event for the current model/tool loop iteration. */ - private emitAssistantStage( - context: AgentRunContext, - iteration: number, - options: RunInternalOptions, - ): void { - emitAgentEvent( - this.eventBus, - { - type: "conversation.assistant_stage", - messageId: context.runId, - stage: iteration === 1 ? "thinking" : "executing", - metadata: createEventMetadata(context.input, context.sessionId, context.traceId), - }, - options, - ); - } - - /** Emits the task-started event used by runtime observers. */ - private emitTaskStarted( - task: TaskRun, - input: AgentRunInput, - sessionId: string, - traceId: string, - options: RunInternalOptions, - ): void { - emitAgentEvent( - this.eventBus, - { - type: "task.started", - taskId: task.id, - prompt: input.prompt, - metadata: createEventMetadata(input, sessionId, traceId), - }, - options, - ); - } - - /** Emits change-set events after tracked workspace changes are checkpointed. */ - private emitChangeSetApplied( - changeSet: ChangeSet, - checkpointPath: string | undefined, - context: AgentRunContext, - options: RunInternalOptions, - ): void { - const files = changeSet.files.map((file) => file.path); - const metadata = createEventMetadata( - context.input, - context.sessionId, - context.traceId, - ); - - emitAgentEvent( - this.eventBus, - {type: "change_set.created", id: changeSet.id, files, metadata}, - options, - ); - emitAgentEvent( - this.eventBus, - { - type: "change_set.applied", - id: changeSet.id, - files, - changes: changeSet.files, - checkpointPath, - metadata, - }, - options, - ); - } - - /** Emits the rollback-started event for a change set. */ - private emitChangeSetRollbackStarted( - changeSet: ChangeSet, - context: AgentRunContext, - options: RunInternalOptions, - ): void { - emitAgentEvent( - this.eventBus, - { - type: "change_set.rollback_started", - id: changeSet.id, - metadata: createEventMetadata(context.input, context.sessionId, context.traceId), - }, - options, - ); - } - - /** Emits the rollback-completed event for a change set. */ - private emitChangeSetRollbackCompleted( - changeSet: ChangeSet, - context: AgentRunContext, - options: RunInternalOptions, - ): void { - emitAgentEvent( - this.eventBus, - { - type: "change_set.rollback_completed", - id: changeSet.id, - metadata: createEventMetadata(context.input, context.sessionId, context.traceId), - }, - options, - ); - } - - /** Emits the verification-started event with selected verification commands. */ - private emitVerificationStarted( - commands: readonly string[], - input: AgentRunInput, - sessionId: string, - traceId: string, - options: RunInternalOptions, - ): void { - emitAgentEvent( - this.eventBus, - { - type: "verification.started", - commands, - metadata: createEventMetadata(input, sessionId, traceId), - }, - options, - ); - } - - /** Emits the verification-completed event for one verification batch. */ - private emitVerificationCompleted( - results: readonly VerificationResult[], - input: AgentRunInput, - sessionId: string, - traceId: string, - options: RunInternalOptions, - ): void { - emitAgentEvent( - this.eventBus, - { - type: "verification.completed", - passed: results.every((result) => result.passed), - commands: results.map((result) => result.command), - metadata: createEventMetadata(input, sessionId, traceId), - }, - options, - ); - } - - /** Emits the trace-persisted event when trace storage produced a file path. */ - private emitTracePersisted( - tracePath: string | undefined, - input: AgentRunInput, - sessionId: string, - traceId: string, - options: RunInternalOptions, - ): void { - if (!tracePath) { - return; - } - - emitAgentEvent( - this.eventBus, - { - type: "trace.persisted", - path: tracePath, - metadata: createEventMetadata(input, sessionId, traceId), - }, - options, - ); - } - - /** Returns current EventBus history filtered to one trace for trace persistence. */ - private eventsForTrace(traceId: string): PixelleEvent[] { - return this.eventBus.history().filter((event) => event.metadata?.traceId === traceId); } } -/** Creates a default agent runtime from either full options or loaded config. */ +/** + * Alias kept for consumers that prefer the explicit CodingAgent name. + */ +export const CodingAgent = Agent; + +/** + * Creates an agent runtime from either full agent options or a loaded config. + */ export function createAgentRuntime(options: AgentOptions): Agent; export function createAgentRuntime(config: AgentConfig): Agent; export function createAgentRuntime(input: AgentOptions | AgentConfig): Agent { @@ -1170,7 +347,9 @@ export function createAgentRuntime(input: AgentOptions | AgentConfig): Agent { return new Agent(input as AgentOptions); } -/** Creates the product runtime by loading Pixelle config from pixelle.toml. */ +/** + * Creates the product runtime by loading Pixelle config from pixelle.toml. + */ export async function createAgentRuntimeFromConfig( options: CreateAgentRuntimeFromConfigOptions = {}, ): Promise { @@ -1182,116 +361,3 @@ export async function createAgentRuntimeFromConfig( config, }); } - -/** Creates the initial task record tracked through the agent run lifecycle. */ -function createTaskRun(runId: string): TaskRun { - const now = Date.now(); - - return { - id: runId, - runId, - status: "created", - createdAt: now, - updatedAt: now, - steps: [ - {id: "scan", title: "Scan workspace", status: "pending"}, - {id: "execute", title: "Execute agent loop", status: "pending"}, - {id: "verify", title: "Verify result", status: "pending"}, - ], - }; -} - -/** Builds the prompt used to ask the model to repair a failed verification command. */ -function buildRepairPrompt(failure: VerificationResult, repairAttempt: number): string { - const output = [failure.stderr, failure.stdout].filter(Boolean).join("\n\n"); - - return [ - `Verification failed on repair attempt ${repairAttempt}.`, - `Command: ${failure.command}`, - `Exit code: ${failure.exitCode ?? "none"}`, - "Fix the issue using the available tools, then stop when the change is ready for verification.", - "Verification output:", - output.slice(0, 12_000), - ].join("\n\n"); -} - -const TRACE_MESSAGE_CONTENT_LIMIT = 4_000; -const TRACE_TOOL_ARGUMENT_LIMIT = 4_000; -const TRACE_TOOL_COUNT_LIMIT = 32; - -function createTraceModelRequest(request: AgentModelRequest): AgentModelRequest { - return { - ...request, - messages: request.messages.map(createTraceMessage), - tools: request.tools?.slice(0, TRACE_TOOL_COUNT_LIMIT), - }; -} - -function createTraceModelResponse(response: AgentModelResponse): AgentModelResponse { - return { - ...response, - content: truncateTraceString(response.content, TRACE_MESSAGE_CONTENT_LIMIT), - toolCalls: response.toolCalls.map((toolCall) => ({ - ...toolCall, - arguments: truncateTraceRecord(toolCall.arguments, TRACE_TOOL_ARGUMENT_LIMIT), - })), - }; -} - -function createTraceMessage(message: LLMMessage): LLMMessage { - if (message.role === "assistant") { - return { - ...message, - content: - message.content === undefined - ? undefined - : truncateTraceString(message.content, TRACE_MESSAGE_CONTENT_LIMIT), - toolCalls: message.toolCalls?.map((toolCall) => ({ - ...toolCall, - arguments: truncateTraceRecord(toolCall.arguments, TRACE_TOOL_ARGUMENT_LIMIT), - })), - }; - } - - return { - ...message, - content: truncateTraceString(message.content, TRACE_MESSAGE_CONTENT_LIMIT), - }; -} - -function truncateTraceString(value: string, limit: number): string { - if (value.length <= limit) { - return value; - } - - return `${value.slice(0, limit)}\n\n[trace truncated ${value.length - limit} chars]`; -} - -function truncateTraceJson(value: unknown, limit: number): unknown { - const serialized = safeJsonStringify(value); - if (!serialized || serialized.length <= limit) { - return value; - } - - return `[trace truncated ${serialized.length - limit} chars] ${serialized.slice(0, limit)}`; -} - -function truncateTraceRecord( - value: Record, - limit: number, -): Record { - const truncated = truncateTraceJson(value, limit); - if (typeof truncated === "string") { - return {__pixelleTraceTruncated: truncated}; - } - - return value; -} - -function safeJsonStringify(value: unknown): string | undefined { - try { - return JSON.stringify(value); - } catch { - return undefined; - } -} diff --git a/src/agent/context.ts b/src/agent/context.ts deleted file mode 100644 index 35649d6..0000000 --- a/src/agent/context.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { - AgentContextProvider, - AgentContextValue, - AgentRunContext, - RunInternalOptions, -} from "./types.js"; -import type {EventBus, PixelleEvent} from "../events/index.js"; -import { - CLI_MARKDOWN_OUTPUT_INSTRUCTIONS, - createEventMetadata, - DEFAULT_SYSTEM_PROMPT, - emitAgentEvent, -} from "./runtime-utils.js"; - -/** Builds the reserved runtime context section injected into the system prompt. */ -export async function buildRuntimeContext(input: { - context: AgentRunContext; - contextProviders: readonly AgentContextProvider[]; - eventBus: EventBus; - options: RunInternalOptions; -}): Promise { - const {context} = input; - - const providers = [ - ...input.contextProviders, - ...(context.input.contextProviders ?? []), - ]; - const values = [...(context.input.context ?? [])]; - - for (const provider of providers) { - const value = await provider.build(context); - values.push( - typeof value === "string" - ? {title: provider.name, content: value} - : {title: value.title ?? provider.name, ...value}, - ); - } - - const contextText = truncateContext( - values.sort(compareContextValue).map(formatContextValue).filter(Boolean), - context.config.runtime.tokensLimit, - ); - emitAgentEvent( - input.eventBus, - { - type: "runtime.context_built", - tokenEstimate: estimateTokens(contextText), - metadata: createEventMetadata(context.input, context.sessionId, context.traceId), - }, - input.options, - ); - - return contextText; -} - -/** Combines the configured system prompt with the reserved runtime context. */ -export function buildSystemPrompt(context: AgentRunContext, contextText: string): string { - const systemPrompt = - context.input.systemPrompt ?? - context.config.runtime.systemPrompt ?? - DEFAULT_SYSTEM_PROMPT; - const promptWithCliInstructions = `${systemPrompt}\n\n${CLI_MARKDOWN_OUTPUT_INSTRUCTIONS}`; - - if (!contextText) { - return promptWithCliInstructions; - } - - return `${promptWithCliInstructions}\n\n# Runtime Context\n${contextText}`; -} - -export function estimateTokens(text: string): number { - return Math.ceil(text.length / 4); -} - -function formatContextValue(value: AgentContextValue): string { - if (typeof value === "string") { - return value.trim(); - } - - const content = value.content.trim(); - if (!content) { - return ""; - } - - return value.title ? `## ${value.title}\n${content}` : content; -} - -function compareContextValue(left: AgentContextValue, right: AgentContextValue): number { - return getContextPriority(right) - getContextPriority(left); -} - -function getContextPriority(value: AgentContextValue): number { - return typeof value === "string" ? 0 : (value.priority ?? 0); -} - -function truncateContext(blocks: string[], tokenLimit: number): string { - const maxChars = Math.max(0, Math.floor(tokenLimit * 4 * 0.35)); - let remaining = maxChars; - const selectedBlocks: string[] = []; - - for (const block of blocks) { - if (remaining <= 0) { - break; - } - - const separatorLength = selectedBlocks.length ? 2 : 0; - const allowed = remaining - separatorLength; - if (allowed <= 0) { - break; - } - - selectedBlocks.push(block.length > allowed ? block.slice(0, allowed) : block); - remaining -= Math.min(block.length, allowed) + separatorLength; - } - - return selectedBlocks.join("\n\n"); -} diff --git a/src/agent/index.ts b/src/agent/index.ts index c676b0b..e90f2bc 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -1,4 +1,40 @@ -export {Agent, createAgentRuntime, createAgentRuntimeFromConfig} from "./agent.js"; +export { + Agent, + CodingAgent, + createAgentRuntime, + createAgentRuntimeFromConfig, +} from "./agent.js"; +export { + AgentObserver, + AgentRunState, + ChangeRuntime, + ContextManager, + ModelRuntime, + RuntimePolicy, + ToolRuntime, + VerificationPipeline, + WorkspaceService, + createAgentObserver, + createChangeRuntime, + createContextManager, + createModelRuntime, + createNoopMemory, + createRuntimePolicy, + createToolRuntime, + createVerificationPipeline, + createWorkspaceService, +} from "./runtime/index.js"; +export type { + AgentMemory, + AgentRunStateOptions, + ChangeRuntimeOptions, + ContextManagerOptions, + ModelRuntimeOptions, + RuntimePolicyOptions, + ToolRuntimeOptions, + VerificationPipelineOptions, + WorkspaceServiceOptions, +} from "./runtime/index.js"; export type { AgentContextProvider, AgentContextValue, diff --git a/src/agent/runtime-utils.ts b/src/agent/runtime-utils.ts index fcd4751..33ee0d9 100644 --- a/src/agent/runtime-utils.ts +++ b/src/agent/runtime-utils.ts @@ -1,15 +1,13 @@ import type {AgentConfig} from "../config/index.js"; import type {EventBus, PixelleEvent} from "../events/index.js"; import type {BaseLLMClient} from "../llm/index.js"; -import type {LLMTool, LLMUsage} from "../llm/types.js"; +import type {LLMUsage} from "../llm/types.js"; import type {CommandPolicyLike, WorkspaceProfile} from "../runtime/index.js"; import { - toLLMToolParametersSchema, type ToolContext, type ToolFileWriter, type ToolPermissions, type ToolStreamChunk, - type ToolRegistry, type ToolResult, } from "../tool/index.js"; import type {AgentRunInput, AgentRuntimeConfig, RunInternalOptions} from "./types.js"; @@ -94,15 +92,6 @@ export function emitAgentEvent( options.eventSink?.(publishedEvent ?? event); } -/** Converts registered runtime tools into provider-neutral LLM tool schemas. */ -export function buildLLMTools(toolRegistry: ToolRegistry): LLMTool[] { - return toolRegistry.listDefinitions().map((definition) => ({ - name: definition.name, - description: definition.description, - inputSchema: toLLMToolParametersSchema(definition.parameters), - })); -} - /** Merges conservative defaults, agent-level permissions, and per-run overrides. */ export function mergePermissions( base?: ToolPermissions, diff --git a/src/agent/runtime/change-runtime.ts b/src/agent/runtime/change-runtime.ts new file mode 100644 index 0000000..02e1b5d --- /dev/null +++ b/src/agent/runtime/change-runtime.ts @@ -0,0 +1,92 @@ +import { + ChangeTracker, + JsonCheckpointStore, + type CheckpointStore, +} from "../../runtime/index.js"; +import type {ToolFileWriter} from "../../tool/index.js"; +import type {AgentRuntimeConfig} from "../types.js"; +import type {AgentObserver} from "./observer.js"; +import type {AgentRunState} from "./run-state.js"; +import type {RuntimePolicy} from "./policy.js"; +import type {WorkspaceService} from "./workspace-service.js"; + +/** Dependencies required to track, checkpoint, and roll back file changes. */ +export type ChangeRuntimeOptions = { + /** Normalized agent config used for checkpoint storage defaults. */ + config: AgentRuntimeConfig; + /** Workspace service that supplies the workspace root. */ + workspace: WorkspaceService; + /** Runtime policy dependency reserved for change safety decisions. */ + policy: RuntimePolicy; + /** Observer used to emit change-set lifecycle events. */ + observer: AgentObserver; + /** Optional checkpoint store override. */ + checkpointStore?: CheckpointStore; +}; + +/** Owns run-scoped change tracking, checkpoints, and rollback behavior. */ +export class ChangeRuntime { + private readonly trackers = new WeakMap(); + + /** Creates a change runtime that lazily creates one ChangeTracker per run. */ + constructor(private readonly options: ChangeRuntimeOptions) {} + + /** Creates the run-scoped file writer and exposes it to tools through run context. */ + prepare(run: AgentRunState): ToolFileWriter { + const tracker = new ChangeTracker({ + runId: run.runId, + workspaceRoot: this.options.workspace.root, + checkpointStore: + this.options.checkpointStore ?? + new JsonCheckpointStore( + this.options.config.trace?.directory ?? this.options.workspace.root, + run.runId, + ), + }); + this.trackers.set(run, tracker); + run.context.fileWriter = tracker; + return tracker; + } + + /** Persists the current dirty change set, updates run state, and emits events. */ + async checkpoint(run: AgentRunState): Promise { + const tracker = this.requireTracker(run); + const checkpoint = await tracker.checkpoint(); + if (!checkpoint.changeSet) { + return; + } + + run.changes.push(checkpoint.changeSet); + run.checkpointPath = checkpoint.path ?? run.checkpointPath; + this.options.observer.changeSetApplied(run, checkpoint.changeSet, checkpoint.path); + } + + /** Rolls back checkpointed changes in reverse order for a failed run. */ + async rollback(run: AgentRunState): Promise { + if (!run.changes.length) { + return; + } + + const tracker = this.requireTracker(run); + run.task.status = "rolled_back"; + for (const changeSet of [...run.changes].reverse()) { + this.options.observer.changeSetRollbackStarted(run, changeSet); + await tracker.rollback(changeSet); + this.options.observer.changeSetRollbackCompleted(run, changeSet); + } + } + + private requireTracker(run: AgentRunState): ChangeTracker { + const tracker = this.trackers.get(run); + if (!tracker) { + throw new Error("ChangeRuntime was not prepared for this run."); + } + + return tracker; + } +} + +/** Creates the default change runtime. */ +export function createChangeRuntime(options: ChangeRuntimeOptions): ChangeRuntime { + return new ChangeRuntime(options); +} diff --git a/src/agent/runtime/context-manager.ts b/src/agent/runtime/context-manager.ts new file mode 100644 index 0000000..ae7416f --- /dev/null +++ b/src/agent/runtime/context-manager.ts @@ -0,0 +1,189 @@ +import type {LLMMessage} from "../../llm/types.js"; +import type { + AgentContextProvider, + AgentContextValue, + AgentModelResponse, + AgentRuntimeConfig, + AgentToolResult, +} from "../types.js"; +import { + CLI_MARKDOWN_OUTPUT_INSTRUCTIONS, + DEFAULT_SYSTEM_PROMPT, + stringifyToolResult, +} from "../runtime-utils.js"; +import type {AgentMemory} from "./memory.js"; +import type {AgentObserver} from "./observer.js"; +import type {AgentRunState} from "./run-state.js"; +import type {WorkspaceService} from "./workspace-service.js"; + +/** Dependencies used to build and maintain the model transcript. */ +export type ContextManagerOptions = { + /** Normalized agent config for prompt and token-limit settings. */ + config: AgentRuntimeConfig; + /** Workspace service whose profile is injected into runtime context. */ + workspace: WorkspaceService; + /** Memory implementation used to load contextual knowledge. */ + memory: AgentMemory; + /** Observer used to emit context lifecycle events. */ + observer: AgentObserver; + /** Agent-level context providers invoked for every run. */ + contextProviders?: readonly AgentContextProvider[]; +}; + +/** Builds model context and owns transcript mutation for assistant/tool turns. */ +export class ContextManager { + private readonly contextProviders: AgentContextProvider[]; + + /** Creates a context manager with static agent-level context providers. */ + constructor(private readonly options: ContextManagerOptions) { + this.contextProviders = [...(options.contextProviders ?? [])]; + } + + /** Builds the initial system, history, and user messages for a run. */ + async prepare(run: AgentRunState): Promise { + const values = [ + ...(await this.loadMemory(run)), + ...(run.input.context ?? []), + { + title: "Workspace Profile", + priority: 100, + content: JSON.stringify(run.workspaceProfile, null, 2), + }, + ]; + const providers = [...this.contextProviders, ...(run.input.contextProviders ?? [])]; + + for (const provider of providers) { + const value = await provider.build(run.context); + values.push( + typeof value === "string" + ? {title: provider.name, content: value} + : {title: value.title ?? provider.name, ...value}, + ); + } + + const contextText = truncateContext( + values.sort(compareContextValue).map(formatContextValue).filter(Boolean), + this.options.config.runtime.tokensLimit, + ); + this.options.observer.contextBuilt(run, estimateTokens(contextText)); + const systemPrompt = buildSystemPrompt( + run.input.systemPrompt ?? this.options.config.runtime.systemPrompt, + contextText, + ); + + run.messages.push({role: "system", content: systemPrompt}); + run.messages.push(...(run.input.messages ?? [])); + run.messages.push({role: "user", content: run.input.prompt}); + } + + /** Returns the current transcript to send to the model runtime. */ + buildModelRequest(run: AgentRunState): readonly LLMMessage[] { + return run.messages; + } + + /** Appends a normalized assistant response to the transcript. */ + appendAssistantResponse(run: AgentRunState, response: AgentModelResponse): void { + run.messages.push({ + role: "assistant", + content: response.content, + toolCalls: response.toolCalls, + }); + } + + /** Appends model-readable tool result messages to the transcript. */ + appendToolResults(run: AgentRunState, toolResults: readonly AgentToolResult[]): void { + for (const toolResult of toolResults) { + run.messages.push({ + role: "tool", + toolCallId: toolResult.call.id, + name: toolResult.call.name, + content: stringifyToolResult(toolResult.result), + }); + } + } + + /** Appends a repair prompt as a user message before a repair model call. */ + appendRepairPrompt(run: AgentRunState, content: string): void { + run.messages.push({role: "user", content}); + } + + /** Gives the memory implementation a chance to persist run knowledge. */ + async save(run: AgentRunState): Promise { + await this.options.memory.saveRunMemory?.(run); + } + + private async loadMemory(run: AgentRunState): Promise { + const runMemory = await this.options.memory.loadRunMemory?.(run); + const projectMemory = await this.options.memory.loadProjectMemory?.(run); + return [...(projectMemory ?? []), ...(runMemory ?? [])]; + } +} + +/** Creates the default context manager. */ +export function createContextManager(options: ContextManagerOptions): ContextManager { + return new ContextManager(options); +} + +/** Estimates tokens using the runtime's coarse character-based heuristic. */ +export function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +/** Combines the configured system prompt, CLI instructions, and runtime context. */ +function buildSystemPrompt( + systemPrompt: string | undefined, + contextText: string, +): string { + const prompt = systemPrompt ?? DEFAULT_SYSTEM_PROMPT; + const promptWithCliInstructions = `${prompt}\n\n${CLI_MARKDOWN_OUTPUT_INSTRUCTIONS}`; + + if (!contextText) { + return promptWithCliInstructions; + } + + return `${promptWithCliInstructions}\n\n# Runtime Context\n${contextText}`; +} + +function formatContextValue(value: AgentContextValue): string { + if (typeof value === "string") { + return value.trim(); + } + + const content = value.content.trim(); + if (!content) { + return ""; + } + + return value.title ? `## ${value.title}\n${content}` : content; +} + +function compareContextValue(left: AgentContextValue, right: AgentContextValue): number { + return getContextPriority(right) - getContextPriority(left); +} + +function getContextPriority(value: AgentContextValue): number { + return typeof value === "string" ? 0 : (value.priority ?? 0); +} + +function truncateContext(blocks: string[], tokenLimit: number): string { + const maxChars = Math.max(0, Math.floor(tokenLimit * 4 * 0.35)); + let remaining = maxChars; + const selectedBlocks: string[] = []; + + for (const block of blocks) { + if (remaining <= 0) { + break; + } + + const separatorLength = selectedBlocks.length ? 2 : 0; + const allowed = remaining - separatorLength; + if (allowed <= 0) { + break; + } + + selectedBlocks.push(block.length > allowed ? block.slice(0, allowed) : block); + remaining -= Math.min(block.length, allowed) + separatorLength; + } + + return selectedBlocks.join("\n\n"); +} diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts new file mode 100644 index 0000000..2d16103 --- /dev/null +++ b/src/agent/runtime/index.ts @@ -0,0 +1,29 @@ +/** Public exports for the modular agent runtime subsystem. */ +export {ChangeRuntime, createChangeRuntime} from "./change-runtime.js"; +export type {ChangeRuntimeOptions} from "./change-runtime.js"; +export {ContextManager, createContextManager} from "./context-manager.js"; +export type {ContextManagerOptions} from "./context-manager.js"; +export {createNoopMemory} from "./memory.js"; +export type {AgentMemory} from "./memory.js"; +export {createModelRuntime, ModelRuntime} from "./model-runtime.js"; +export type {ModelRuntimeOptions} from "./model-runtime.js"; +export {AgentObserver, createAgentObserver} from "./observer.js"; +export type {AgentObserverOptions} from "./observer.js"; +export {createRuntimePolicy, RuntimePolicy} from "./policy.js"; +export type {RuntimePolicyOptions} from "./policy.js"; +export {AgentRunState} from "./run-state.js"; +export type {AgentRunStateOptions} from "./run-state.js"; +export { + createToolRuntime, + registerToolRuntimeRun, + ToolRuntime, + unregisterToolRuntimeRun, +} from "./tool-runtime.js"; +export type {ToolRuntimeOptions} from "./tool-runtime.js"; +export { + createVerificationPipeline, + VerificationPipeline, +} from "./verification-pipeline.js"; +export type {VerificationPipelineOptions} from "./verification-pipeline.js"; +export {createWorkspaceService, WorkspaceService} from "./workspace-service.js"; +export type {WorkspaceServiceOptions} from "./workspace-service.js"; diff --git a/src/agent/runtime/memory.ts b/src/agent/runtime/memory.ts new file mode 100644 index 0000000..32e6746 --- /dev/null +++ b/src/agent/runtime/memory.ts @@ -0,0 +1,25 @@ +import type {AgentContextValue} from "../types.js"; +import type {AgentRunState} from "./run-state.js"; + +/** Memory boundary used by ContextManager to load and persist contextual knowledge. */ +export type AgentMemory = { + /** Loads memory scoped to the current run or conversation. */ + loadRunMemory?( + run: AgentRunState, + ): Promise | readonly AgentContextValue[]; + /** Loads durable project-level memory and preferences. */ + loadProjectMemory?( + run: AgentRunState, + ): Promise | readonly AgentContextValue[]; + /** Persists any valuable information after the run has completed. */ + saveRunMemory?(run: AgentRunState): Promise | void; +}; + +/** Creates a memory implementation that intentionally stores and returns nothing. */ +export function createNoopMemory(): AgentMemory { + return { + loadRunMemory: () => [], + loadProjectMemory: () => [], + saveRunMemory: () => {}, + }; +} diff --git a/src/agent/runtime/model-runtime.ts b/src/agent/runtime/model-runtime.ts new file mode 100644 index 0000000..916ba69 --- /dev/null +++ b/src/agent/runtime/model-runtime.ts @@ -0,0 +1,105 @@ +import {LLMClient, type BaseLLMClient} from "../../llm/index.js"; +import type {LLMGenerateInput} from "../../llm/types.js"; +import {mergeUsage, missingLLMClient} from "../runtime-utils.js"; +import type { + AgentModelRequest, + AgentModelResponse, + AgentRuntimeConfig, +} from "../types.js"; +import type {AgentMiddlewarePipeline} from "../middleware.js"; +import type {AgentObserver} from "./observer.js"; +import type {AgentRunState} from "./run-state.js"; + +/** Dependencies used to invoke the configured language model. */ +export type ModelRuntimeOptions = { + /** Normalized config for model settings and retry/timeout options. */ + config: AgentRuntimeConfig; + /** Optional model client override. */ + llm?: BaseLLMClient; + /** Middleware pipeline for model request/response hooks. */ + middleware: AgentMiddlewarePipeline; + /** Observer used for streaming deltas and model lifecycle hooks. */ + observer: AgentObserver; +}; + +/** Encapsulates model requests, streaming fallback, usage accounting, and middleware. */ +export class ModelRuntime { + private readonly llm: BaseLLMClient; + + /** Creates a model runtime from an explicit client or normalized LLM config. */ + constructor(private readonly options: ModelRuntimeOptions) { + this.llm = + options.llm ?? + (options.config.llm ? new LLMClient(options.config.llm) : missingLLMClient()); + } + + /** Generates one assistant response for the current run and updates run usage/content. */ + async generate( + input: Omit, + run: AgentRunState, + ): Promise { + const request = await this.options.middleware.beforeModel( + { + ...input, + timeoutMs: this.options.config.llm?.timeoutMs, + maxRetries: this.options.config.llm?.maxRetries, + iteration: run.iteration, + runId: run.runId, + }, + run.context, + ); + const response = await this.generateStreamingModelResponse(request, run); + const modelResponse = await this.options.middleware.afterModel( + { + ...response, + iteration: run.iteration, + runId: run.runId, + }, + run.context, + ); + run.usage = mergeUsage(run.usage, modelResponse.usage); + run.content = modelResponse.content || run.content; + this.options.observer.modelCompleted(run, modelResponse); + return modelResponse; + } + + private async generateStreamingModelResponse( + request: AgentModelRequest, + run: AgentRunState, + ): Promise> { + let streamedContent = ""; + let emittedContent = false; + let finalResponse: Omit | undefined; + + try { + for await (const chunk of this.llm.stream(request)) { + if (chunk.type === "content_delta") { + streamedContent += chunk.content; + emittedContent = true; + this.options.observer.assistantDelta(run, chunk.content); + continue; + } + + if (chunk.type === "done") { + finalResponse = { + ...chunk.response, + content: chunk.response.content || streamedContent, + }; + } + } + } catch (error) { + if (emittedContent) { + throw error; + } + + return this.llm.generate(request); + } + + return finalResponse ?? this.llm.generate(request); + } +} + +/** Creates the default model runtime. */ +export function createModelRuntime(options: ModelRuntimeOptions): ModelRuntime { + return new ModelRuntime(options); +} diff --git a/src/agent/runtime/observer.ts b/src/agent/runtime/observer.ts new file mode 100644 index 0000000..fb37ceb --- /dev/null +++ b/src/agent/runtime/observer.ts @@ -0,0 +1,238 @@ +import type {EventBus, PixelleEvent} from "../../events/index.js"; +import type {VerificationResult, ChangeSet} from "../../runtime/index.js"; +import type {ToolResult, ToolStreamChunk} from "../../tool/index.js"; +import {createEventMetadata, emitAgentEvent} from "../runtime-utils.js"; +import type {AgentModelResponse, AgentToolCall, RunInternalOptions} from "../types.js"; +import type {AgentRunState} from "./run-state.js"; + +/** Constructor options for the event-backed default observer. */ +export type AgentObserverOptions = { + /** Event bus that receives public runtime, conversation, tool, and change events. */ + eventBus: EventBus; +}; + +/** Emits public agent lifecycle events while hiding module implementation details. */ +export class AgentObserver { + /** Creates an observer that publishes to the supplied event bus. */ + constructor(private readonly options: AgentObserverOptions) {} + + /** Event bus used by this observer; exposed for compatibility and tests. */ + get eventBus(): EventBus { + return this.options.eventBus; + } + + /** Emits the standard session, status, user-message, and task-start events. */ + runStarted(run: AgentRunState): void { + const metadata = this.metadata(run); + this.emit(run, {type: "runtime.session_started", sessionId: run.sessionId, metadata}); + this.emit(run, {type: "runtime.status_changed", status: "running", metadata}); + this.emit(run, { + type: "conversation.user_message", + content: run.input.prompt, + metadata, + }); + this.emit(run, { + type: "task.started", + taskId: run.task.id, + prompt: run.input.prompt, + metadata, + }); + } + + /** Emits the assistant stage for the current model/tool loop iteration. */ + assistantStage(run: AgentRunState): void { + this.emit(run, { + type: "conversation.assistant_stage", + messageId: run.runId, + stage: run.iteration === 1 ? "thinking" : "executing", + metadata: this.metadata(run), + }); + } + + /** Emits a streamed assistant content delta from the model runtime. */ + assistantDelta(run: AgentRunState, delta: string): void { + this.emit(run, { + type: "conversation.assistant_delta", + messageId: run.runId, + delta, + stage: "thinking", + metadata: this.metadata(run), + }); + } + + /** Emits the runtime context-built event with an estimated token count. */ + contextBuilt(run: AgentRunState, tokenEstimate: number): void { + this.emit(run, { + type: "runtime.context_built", + tokenEstimate, + metadata: this.metadata(run), + }); + } + + /** Emits the public tool-started event for a model-requested tool call. */ + toolStarted(run: AgentRunState, call: AgentToolCall): void { + this.emit(run, { + type: "tool.call_started", + id: call.id, + name: call.name, + input: call.arguments, + status: "running", + metadata: this.metadata(run), + }); + } + + /** Emits incremental output produced while a tool is running. */ + toolStreamed(run: AgentRunState, call: AgentToolCall, stream: ToolStreamChunk): void { + this.emit(run, { + type: "tool.call_stream", + id: call.id, + name: call.name, + stream, + metadata: this.metadata(run), + }); + } + + /** Emits a public completed or failed tool event after middleware finalizes the result. */ + toolCompleted(run: AgentRunState, call: AgentToolCall, result: ToolResult): void { + if (result.ok) { + this.emit(run, { + type: "tool.call_completed", + id: call.id, + name: call.name, + result, + output: result.data, + summary: result.message, + display: result.display, + metadata: this.metadata(run), + }); + return; + } + + this.emit(run, { + type: "tool.call_failed", + id: call.id, + name: call.name, + result, + error: result.message, + code: result.code, + data: result.data, + display: result.display, + metadata: this.metadata(run), + }); + } + + /** Emits change-set created and applied events for a checkpointed set of edits. */ + changeSetApplied( + run: AgentRunState, + changeSet: ChangeSet, + checkpointPath?: string, + ): void { + const files = changeSet.files.map((file) => file.path); + const metadata = this.metadata(run); + this.emit(run, {type: "change_set.created", id: changeSet.id, files, metadata}); + this.emit(run, { + type: "change_set.applied", + id: changeSet.id, + files, + changes: changeSet.files, + checkpointPath, + metadata, + }); + } + + /** Emits the rollback-started event for one change set. */ + changeSetRollbackStarted(run: AgentRunState, changeSet: ChangeSet): void { + this.emit(run, { + type: "change_set.rollback_started", + id: changeSet.id, + metadata: this.metadata(run), + }); + } + + /** Emits the rollback-completed event for one change set. */ + changeSetRollbackCompleted(run: AgentRunState, changeSet: ChangeSet): void { + this.emit(run, { + type: "change_set.rollback_completed", + id: changeSet.id, + metadata: this.metadata(run), + }); + } + + /** Emits verification-started with the selected command list. */ + verificationStarted(run: AgentRunState, commands: readonly string[]): void { + this.emit(run, { + type: "verification.started", + commands, + metadata: this.metadata(run), + }); + } + + /** Emits verification-completed for one verification batch. */ + verificationCompleted( + run: AgentRunState, + results: readonly VerificationResult[], + ): void { + this.emit(run, { + type: "verification.completed", + passed: results.every((result) => result.passed), + commands: results.map((result) => result.command), + metadata: this.metadata(run), + }); + } + + /** Emits normal run completion and final session status events. */ + runCompleted(run: AgentRunState): void { + const metadata = this.metadata(run); + this.emit(run, { + type: "conversation.assistant_done", + messageId: run.runId, + metadata, + }); + this.emit(run, { + type: "runtime.status_changed", + status: run.stopReason === "completed" ? "complete" : "waiting", + detail: run.stopReason, + metadata, + }); + this.emit(run, {type: "runtime.session_stopped", sessionId: run.sessionId, metadata}); + } + + /** Emits failure status and session shutdown events for unexpected run errors. */ + runFailed(run: AgentRunState, error: unknown): void { + const metadata = this.metadata(run); + this.emit(run, { + type: "runtime.error", + message: error instanceof Error ? error.message : "Agent run failed.", + detail: error, + metadata, + }); + this.emit(run, { + type: "runtime.status_changed", + status: "error", + detail: run.stopReason, + metadata, + }); + this.emit(run, {type: "runtime.session_stopped", sessionId: run.sessionId, metadata}); + } + + /** Hook for future model-completed observer behavior. */ + modelCompleted(_run: AgentRunState, _response: AgentModelResponse): void {} + + /** Builds shared event metadata for all events emitted during a run. */ + metadata(run: AgentRunState): Record { + return createEventMetadata(run.input, run.sessionId, run.traceId); + } + + private emit(run: AgentRunState, event: PixelleEvent): void { + emitAgentEvent( + this.options.eventBus, + event, + run.internalOptions as RunInternalOptions, + ); + } +} + +/** Creates the default event-backed observer. */ +export function createAgentObserver(options: AgentObserverOptions): AgentObserver { + return new AgentObserver(options); +} diff --git a/src/agent/runtime/policy.ts b/src/agent/runtime/policy.ts new file mode 100644 index 0000000..4009833 --- /dev/null +++ b/src/agent/runtime/policy.ts @@ -0,0 +1,37 @@ +import {createCommandPolicy, type CommandPolicyLike} from "../../runtime/index.js"; +import type {ToolPermissions} from "../../tool/index.js"; +import {mergePermissions} from "../runtime-utils.js"; +import type {AgentRuntimeConfig} from "../types.js"; + +/** Policy inputs used to normalize permissions and command execution rules. */ +export type RuntimePolicyOptions = { + /** Agent-level permission defaults from normalized config. */ + config: AgentRuntimeConfig["permissions"]; + /** Optional caller override merged on top of configured permissions. */ + permissions?: ToolPermissions; + /** Optional command policy implementation for shell and verification commands. */ + commandPolicy?: CommandPolicyLike; +}; + +/** Central policy object for permissions, command checks, and future approvals. */ +export class RuntimePolicy { + /** Command policy used by shell tools and verification. */ + readonly commandPolicy: CommandPolicyLike; + private readonly permissions: ToolPermissions; + + /** Creates a policy with merged tool permissions and command policy defaults. */ + constructor(options: RuntimePolicyOptions) { + this.permissions = mergePermissions(options.config, options.permissions); + this.commandPolicy = options.commandPolicy ?? createCommandPolicy(); + } + + /** Resolves tool permissions for a run by applying per-run overrides last. */ + toolPermissions(runPermissions?: ToolPermissions): ToolPermissions { + return mergePermissions(this.permissions, runPermissions); + } +} + +/** Creates the default runtime policy. */ +export function createRuntimePolicy(options: RuntimePolicyOptions): RuntimePolicy { + return new RuntimePolicy(options); +} diff --git a/src/agent/runtime/run-state.ts b/src/agent/runtime/run-state.ts new file mode 100644 index 0000000..12ba7de --- /dev/null +++ b/src/agent/runtime/run-state.ts @@ -0,0 +1,154 @@ +import {randomUUID} from "node:crypto"; + +import type {LLMMessage, LLMUsage} from "../../llm/types.js"; +import type { + ChangeSet, + TaskRun, + VerificationResult, + WorkspaceProfile, +} from "../../runtime/index.js"; +import type { + AgentRunContext, + AgentRunInput, + AgentRunResult, + AgentRuntimeConfig, + AgentStopReason, + AgentToolResult, + RunInternalOptions, +} from "../types.js"; + +/** Dependencies and user input required to create one run-scoped state object. */ +export type AgentRunStateOptions = { + /** Agent instance exposed to legacy middleware and context providers. */ + agent: AgentRunContext["agent"]; + /** Normalized runtime configuration used for this run. */ + config: AgentRuntimeConfig; + /** User-facing run input. */ + input: AgentRunInput; + /** Internal stream/session overrides used by Agent.run() and Agent.stream(). */ + internalOptions?: RunInternalOptions; +}; + +/** Mutable state container shared by all runtime modules for one agent run. */ +export class AgentRunState { + /** Stable ID for this logical run. */ + readonly runId: string; + /** Conversation/session ID; defaults to the run ID. */ + readonly sessionId: string; + /** Correlation ID copied into emitted event metadata. */ + readonly traceId: string; + /** Original user input for the run. */ + readonly input: AgentRunInput; + /** Task lifecycle summary surfaced in the final result. */ + readonly task: TaskRun; + /** Full model transcript accumulated during the run. */ + readonly messages: LLMMessage[] = []; + /** Normalized tool results produced by model-requested tool calls. */ + readonly toolResults: AgentToolResult[] = []; + /** Checkpointed change sets created by tools. */ + readonly changes: ChangeSet[] = []; + /** Verification command results from initial and repair attempts. */ + readonly verification: VerificationResult[] = []; + /** Compatibility context passed to middleware and context providers. */ + readonly context: AgentRunContext; + /** Internal run options, including stream event sink. */ + readonly internalOptions: RunInternalOptions; + + /** Workspace metadata discovered before context construction. */ + workspaceProfile?: WorkspaceProfile; + /** Latest assistant content used as final user-facing output. */ + content = ""; + /** Accumulated model usage across all model calls. */ + usage?: LLMUsage; + /** Reason the agent loop stopped. */ + stopReason: AgentStopReason = "completed"; + /** Current model/tool loop iteration. */ + iteration = 0; + /** Latest checkpoint path returned by the change runtime. */ + checkpointPath?: string; + /** Error captured when the run fails unexpectedly. */ + error?: unknown; + + /** Creates a fresh run state and its legacy-compatible AgentRunContext. */ + constructor(options: AgentRunStateOptions) { + this.internalOptions = options.internalOptions ?? {}; + this.runId = this.internalOptions.runId ?? randomUUID(); + this.sessionId = this.internalOptions.sessionId ?? this.runId; + this.traceId = this.internalOptions.traceId ?? randomUUID(); + this.input = options.input; + this.task = createTaskRun(this.runId); + this.context = { + agent: options.agent, + config: options.config, + input: options.input, + iteration: 0, + runId: this.runId, + sessionId: this.sessionId, + signal: options.input.signal, + traceId: this.traceId, + }; + } + + /** Returns whether the model/tool loop may execute another iteration. */ + canContinue(maxIterations: number): boolean { + return !this.input.signal?.aborted && this.iteration < maxIterations; + } + + /** Advances the run iteration and mirrors it onto the middleware context. */ + nextIteration(): number { + this.iteration += 1; + this.context.iteration = this.iteration; + return this.iteration; + } + + /** Marks the run completed and records the final assistant content if supplied. */ + complete(content?: string): void { + this.content = content || this.content; + this.stopReason = "completed"; + } + + /** Marks the run failed, preserving aborts as a distinct stop reason. */ + fail(error?: unknown): void { + this.error = error; + this.stopReason = this.input.signal?.aborted ? "aborted" : "error"; + } + + /** Converts the mutable run state into the public Agent.run() result shape. */ + toResult(): AgentRunResult { + return { + runId: this.runId, + sessionId: this.sessionId, + traceId: this.traceId, + content: this.content, + messages: this.messages, + toolResults: this.toolResults, + usage: this.usage, + iterations: this.iteration, + stopReason: this.stopReason, + task: this.task, + changes: this.changes, + verification: this.verification, + workspaceProfile: this.workspaceProfile, + checkpointPath: this.checkpointPath, + error: this.error, + }; + } +} + +/** Creates the lightweight task record associated with a run. */ +function createTaskRun(runId: string): TaskRun { + const now = Date.now(); + + return { + id: runId, + runId, + status: "created", + createdAt: now, + updatedAt: now, + steps: [ + {id: "scan", title: "Scan workspace", status: "pending"}, + {id: "execute", title: "Execute agent loop", status: "pending"}, + {id: "verify", title: "Verify result", status: "pending"}, + ], + }; +} diff --git a/src/agent/runtime/tool-runtime.ts b/src/agent/runtime/tool-runtime.ts new file mode 100644 index 0000000..39990ae --- /dev/null +++ b/src/agent/runtime/tool-runtime.ts @@ -0,0 +1,142 @@ +import { + createDefaultToolRegistry, + toLLMToolParametersSchema, + ToolRunner, + type ToolRegistry, + type ToolRunnerEvent, +} from "../../tool/index.js"; +import type {LLMTool} from "../../llm/types.js"; +import {createToolContext} from "../runtime-utils.js"; +import type {AgentMiddlewarePipeline} from "../middleware.js"; +import type {AgentToolCall, AgentToolResult, AgentRuntimeConfig} from "../types.js"; +import type {AgentObserver} from "./observer.js"; +import type {AgentRunState} from "./run-state.js"; +import type {RuntimePolicy} from "./policy.js"; +import type {ChangeRuntime} from "./change-runtime.js"; +import type {WorkspaceService} from "./workspace-service.js"; + +/** Dependencies used to expose and execute runtime tools. */ +export type ToolRuntimeOptions = { + /** Normalized agent config reserved for tool runtime options. */ + config: AgentRuntimeConfig; + /** Workspace service that supplies tool workspace root. */ + workspace: WorkspaceService; + /** Runtime policy used to resolve tool permissions and command checks. */ + policy: RuntimePolicy; + /** Change runtime that supplies the run-scoped file writer. */ + changes: ChangeRuntime; + /** Observer used to emit tool lifecycle events. */ + observer: AgentObserver; + /** Middleware pipeline for before/after tool hooks. */ + middleware: AgentMiddlewarePipeline; + /** Optional tool registry override. */ + toolRegistry?: ToolRegistry; + /** Optional runner override for custom execution behavior. */ + toolRunner?: ToolRunner; +}; + +/** Owns tool schemas, execution context construction, and tool lifecycle events. */ +export class ToolRuntime { + private readonly registry: ToolRegistry; + private readonly runner: ToolRunner; + + /** Creates a tool runtime using the default registry and ToolRunner when omitted. */ + constructor(private readonly options: ToolRuntimeOptions) { + this.registry = options.toolRegistry ?? createDefaultToolRegistry(); + this.runner = + options.toolRunner ?? + new ToolRunner(this.registry, { + onEvent: (event) => this.handleRunnerEvent(event), + }); + } + + /** Returns provider-neutral tool schemas to include in model requests. */ + schemas(): LLMTool[] { + return this.registry.listDefinitions().map((definition) => ({ + name: definition.name, + description: definition.description, + inputSchema: toLLMToolParametersSchema(definition.parameters), + })); + } + + /** Executes model-requested tool calls sequentially and records results on run state. */ + async execute( + run: AgentRunState, + toolCalls: readonly AgentToolCall[], + ): Promise { + const results: AgentToolResult[] = []; + + for (const call of toolCalls) { + const toolCall = await this.options.middleware.beforeTool(call, run.context); + this.options.observer.toolStarted(run, toolCall); + const result = await this.runner.run( + toolCall.name, + toolCall.arguments, + createToolContext({ + workspaceRoot: this.options.workspace.root, + signal: run.input.signal, + basePermissions: this.options.policy.toolPermissions(), + runPermissions: run.input.permissions, + fileWriter: run.context.fileWriter, + workspaceProfile: run.workspaceProfile, + commandPolicy: this.options.policy.commandPolicy, + }), + { + callId: toolCall.id, + metadata: this.options.observer.metadata(run), + }, + ); + const toolResult = await this.options.middleware.afterTool( + {call: toolCall, result}, + run.context, + ); + run.toolResults.push(toolResult); + results.push(toolResult); + this.options.observer.toolCompleted(run, toolCall, toolResult.result); + } + + return results; + } + + private handleRunnerEvent(event: ToolRunnerEvent): void { + const run = this.findRunForEvent(event); + if (!run || event.type !== "runner.tool.streamed") { + return; + } + + this.options.observer.toolStreamed( + run, + { + id: event.callId, + name: event.toolName, + arguments: {}, + iteration: run.iteration, + }, + event.stream, + ); + } + + private findRunForEvent(event: ToolRunnerEvent): AgentRunState | undefined { + const traceId = + typeof event.metadata?.traceId === "string" ? event.metadata.traceId : undefined; + return traceId ? activeRunsByTraceId.get(traceId) : undefined; + } +} + +/** Active run lookup used to route ToolRunner stream events back to their run. */ +const activeRunsByTraceId = new Map(); + +/** Registers a run so ToolRunner stream callbacks can find observer metadata. */ +export function registerToolRuntimeRun(run: AgentRunState): void { + activeRunsByTraceId.set(run.traceId, run); +} + +/** Removes a run from the ToolRunner stream-event lookup table. */ +export function unregisterToolRuntimeRun(run: AgentRunState): void { + activeRunsByTraceId.delete(run.traceId); +} + +/** Creates the default tool runtime. */ +export function createToolRuntime(options: ToolRuntimeOptions): ToolRuntime { + return new ToolRuntime(options); +} diff --git a/src/agent/runtime/verification-pipeline.ts b/src/agent/runtime/verification-pipeline.ts new file mode 100644 index 0000000..aacad38 --- /dev/null +++ b/src/agent/runtime/verification-pipeline.ts @@ -0,0 +1,157 @@ +import {Verifier, type VerificationResult} from "../../runtime/index.js"; +import type {AgentRuntimeConfig} from "../types.js"; +import type {ChangeRuntime} from "./change-runtime.js"; +import type {ContextManager} from "./context-manager.js"; +import type {ModelRuntime} from "./model-runtime.js"; +import type {AgentObserver} from "./observer.js"; +import type {RuntimePolicy} from "./policy.js"; +import type {AgentRunState} from "./run-state.js"; +import type {ToolRuntime} from "./tool-runtime.js"; +import type {WorkspaceService} from "./workspace-service.js"; + +/** Dependencies used to verify a run and optionally ask the model to repair it. */ +export type VerificationPipelineOptions = { + /** Normalized config for verification defaults and repair limits. */ + config: AgentRuntimeConfig; + /** Workspace service that supplies the verification working directory. */ + workspace: WorkspaceService; + /** Runtime policy used by the default verifier. */ + policy: RuntimePolicy; + /** Tool runtime used during repair attempts. */ + tools: ToolRuntime; + /** Model runtime used to generate repair responses. */ + model: ModelRuntime; + /** Context manager used to append repair prompts and responses. */ + context: ContextManager; + /** Change runtime used to checkpoint edits made during repair. */ + changes: ChangeRuntime; + /** Observer used to emit verification lifecycle events. */ + observer: AgentObserver; + /** Optional verifier override. */ + verifier?: Verifier; +}; + +/** Runs verification commands and performs bounded model/tool repair attempts. */ +export class VerificationPipeline { + private readonly verifier: Verifier; + + /** Creates a verification pipeline with a policy-backed default verifier. */ + constructor(private readonly options: VerificationPipelineOptions) { + this.verifier = options.verifier ?? new Verifier(options.policy.commandPolicy); + } + + /** Verifies the run, attempts repairs on failures, and updates the final stop reason. */ + async verifyAndRepair(run: AgentRunState, maxIterations: number): Promise { + if (!this.shouldVerify(run) || !run.workspaceProfile) { + return; + } + + run.task.status = "verifying"; + const commands = this.selectCommands(run); + this.options.observer.verificationStarted(run, commands); + const initialResults = await this.verify(run); + run.verification.push(...initialResults); + this.options.observer.verificationCompleted(run, initialResults); + + let failedVerification = run.verification.find((result) => !result.passed); + const maxRepairAttempts = + run.input.maxRepairAttempts ?? this.options.config.runtime.maxRepairAttempts; + + for ( + let repairAttempt = 1; + failedVerification && repairAttempt <= maxRepairAttempts; + repairAttempt += 1 + ) { + if (!run.canContinue(maxIterations)) { + break; + } + + run.task.status = "repairing"; + this.options.context.appendRepairPrompt( + run, + buildRepairPrompt(failedVerification, repairAttempt), + ); + run.nextIteration(); + this.options.observer.assistantStage(run); + const response = await this.options.model.generate( + {messages: run.messages, tools: this.options.tools.schemas()}, + run, + ); + this.options.context.appendAssistantResponse(run, response); + const toolResults = await this.options.tools.execute( + run, + response.toolCalls.map((toolCall) => ({...toolCall, iteration: run.iteration})), + ); + this.options.context.appendToolResults(run, toolResults); + await this.options.changes.checkpoint(run); + + const repairResults = await this.verify(run); + run.verification.push(...repairResults); + this.options.observer.verificationCompleted(run, repairResults); + failedVerification = repairResults.find((result) => !result.passed); + } + + if (failedVerification) { + run.stopReason = "error"; + run.content = + `${run.content}\n\nVerification failed: ${failedVerification.command}`.trim(); + } else { + run.stopReason = "completed"; + } + } + + private shouldVerify(run: AgentRunState): boolean { + return ( + run.input.verification?.enabled ?? + this.options.config.verification?.enabled ?? + run.input.mode !== "ask" + ); + } + + private selectCommands(run: AgentRunState): string[] { + if (!run.workspaceProfile) { + return []; + } + + return this.verifier.selectCommands( + run.workspaceProfile, + run.input.verification?.commands?.length + ? run.input.verification.commands + : this.options.config.verification?.commands, + ); + } + + private verify(run: AgentRunState): Promise { + if (!run.workspaceProfile) { + return Promise.resolve([]); + } + + return this.verifier.verify(this.options.workspace.root, run.workspaceProfile, { + commands: run.input.verification?.commands?.length + ? run.input.verification.commands + : this.options.config.verification?.commands, + signal: run.input.signal, + }); + } +} + +/** Creates the default verification pipeline. */ +export function createVerificationPipeline( + options: VerificationPipelineOptions, +): VerificationPipeline { + return new VerificationPipeline(options); +} + +/** Builds the user message that asks the model to repair one failed command. */ +function buildRepairPrompt(failure: VerificationResult, repairAttempt: number): string { + const output = [failure.stderr, failure.stdout].filter(Boolean).join("\n\n"); + + return [ + `Verification failed on repair attempt ${repairAttempt}.`, + `Command: ${failure.command}`, + `Exit code: ${failure.exitCode ?? "none"}`, + "Fix the issue using the available tools, then stop when the change is ready for verification.", + "Verification output:", + output.slice(0, 12_000), + ].join("\n\n"); +} diff --git a/src/agent/runtime/workspace-service.ts b/src/agent/runtime/workspace-service.ts new file mode 100644 index 0000000..43290c0 --- /dev/null +++ b/src/agent/runtime/workspace-service.ts @@ -0,0 +1,40 @@ +import {WorkspaceScanner, type WorkspaceProfile} from "../../runtime/index.js"; +import type {AgentRunState} from "./run-state.js"; + +/** Constructor options for workspace preparation and discovery. */ +export type WorkspaceServiceOptions = { + /** Root directory all workspace operations are scoped to. */ + workspaceRoot: string; + /** Optional scanner override, primarily for tests or custom workspace discovery. */ + scanner?: WorkspaceScanner; +}; + +/** Prepares workspace metadata for a run and owns the workspace root boundary. */ +export class WorkspaceService { + private readonly scanner: WorkspaceScanner; + + /** Creates a workspace service with the default scanner when none is supplied. */ + constructor(private readonly options: WorkspaceServiceOptions) { + this.scanner = options.scanner ?? new WorkspaceScanner(); + } + + /** Absolute or configured workspace root used by tools and verification. */ + get root(): string { + return this.options.workspaceRoot; + } + + /** Scans the workspace and stores the resulting profile on run state and context. */ + async prepare(run: AgentRunState): Promise { + const profile = await this.scanner.scan(this.root, run.input.signal); + run.workspaceProfile = profile; + run.context.workspaceProfile = profile; + return profile; + } +} + +/** Creates the default workspace service. */ +export function createWorkspaceService( + options: WorkspaceServiceOptions, +): WorkspaceService { + return new WorkspaceService(options); +} diff --git a/src/agent/tool-runner-events.ts b/src/agent/tool-runner-events.ts deleted file mode 100644 index a817843..0000000 --- a/src/agent/tool-runner-events.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type {EventBus, PixelleEvent} from "../events/index.js"; -import type {ToolResult, ToolRunnerEvent} from "../tool/index.js"; -import {inferToolTarget} from "../tool/tool-target.js"; -import {emitAgentEvent} from "./runtime-utils.js"; -import type {RunInternalOptions} from "./types.js"; - -type RunnerLiveEvent = Extract< - ToolRunnerEvent, - {type: "runner.tool.started"} | {type: "runner.tool.streamed"} ->; - -type RunnerTerminalEvent = Exclude; - -/** Bridges live ToolRunner lifecycle events into public Agent events. - * - * Runner events describe raw execution mechanics. Agent events are the public - * semantic stream consumed by CLI, trace, replay, and external observers. Only - * started/streamed events are bridged immediately; terminal events are emitted - * separately after Agent middleware has finalized the ToolResult. - */ -export function emitRunnerLiveEventAsAgentEvent(input: { - eventBus: EventBus; - event: RunnerLiveEvent; - options: RunInternalOptions; -}): void { - const metadata = input.event.metadata; - - switch (input.event.type) { - case "runner.tool.started": - emitAgentEvent( - input.eventBus, - { - type: "tool.call_started", - id: input.event.callId, - name: input.event.toolName, - target: inferToolTarget(input.event.toolName, input.event.input), - input: input.event.input, - status: "running", - metadata, - }, - input.options, - ); - return; - - case "runner.tool.streamed": - emitAgentEvent( - input.eventBus, - { - type: "tool.call_stream", - id: input.event.callId, - name: input.event.toolName, - stream: input.event.stream, - metadata, - }, - input.options, - ); - return; - } -} - -/** Emits the final public Agent tool event from the post-middleware ToolResult. - * - * The terminal runner event contributes timing and metadata only. Its raw - * result is intentionally ignored so afterTool middleware remains the last word - * on what CLI, Trace, Replay, and external consumers observe. - */ -export function emitFinalToolResultAsAgentEvent(input: { - eventBus: EventBus; - runnerEvent: RunnerTerminalEvent; - result: ToolResult; - options: RunInternalOptions; -}): void { - const metadata = input.runnerEvent.metadata; - - if (input.result.ok) { - emitAgentEvent( - input.eventBus, - { - type: "tool.call_completed", - id: input.runnerEvent.callId, - name: input.runnerEvent.toolName, - result: input.result, - durationMs: input.runnerEvent.durationMs, - target: inferToolTarget( - input.runnerEvent.toolName, - input.result.display, - input.result.data, - ), - output: input.result.data, - summary: input.result.message, - display: input.result.display, - metadata, - }, - input.options, - ); - return; - } - - emitAgentEvent( - input.eventBus, - { - type: "tool.call_failed", - id: input.runnerEvent.callId, - name: input.runnerEvent.toolName, - result: input.result, - durationMs: input.runnerEvent.durationMs, - target: inferToolTarget( - input.runnerEvent.toolName, - input.result.display, - input.result.data, - ), - error: input.result.message, - code: input.result.code, - data: input.result.data, - display: input.result.display, - metadata, - }, - input.options, - ); -} diff --git a/src/agent/types.ts b/src/agent/types.ts index 0f2d4b4..72ee15a 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -35,6 +35,17 @@ import type { } from "../runtime/index.js"; import type {EventBus} from "../events/index.js"; import type {Agent} from "./agent.js"; +import type { + AgentMemory, + AgentObserver, + ChangeRuntime, + ContextManager, + ModelRuntime, + RuntimePolicy, + ToolRuntime, + VerificationPipeline, + WorkspaceService, +} from "./runtime/index.js"; /** Reason why an agent run stopped. */ export type AgentStopReason = "completed" | "max_iterations" | "aborted" | "error"; @@ -170,6 +181,15 @@ export type AgentRuntimeConfig = { /** Constructor options for wiring the agent to LLM, tools, events, and hooks. */ export type AgentOptions = { config: AgentRuntimeConfig | AgentConfig; + model?: ModelRuntime; + tools?: ToolRuntime; + context?: ContextManager; + workspace?: WorkspaceService; + memory?: AgentMemory; + policy?: RuntimePolicy; + changes?: ChangeRuntime; + verification?: VerificationPipeline; + observer?: AgentObserver; llm?: BaseLLMClient; toolRegistry?: ToolRegistry; toolRunner?: ToolRunner; diff --git a/tests/agent/system-prompt.test.ts b/tests/agent/system-prompt.test.ts index 4bb23eb..7bef48b 100644 --- a/tests/agent/system-prompt.test.ts +++ b/tests/agent/system-prompt.test.ts @@ -1,26 +1,65 @@ import {describe, expect, it} from "vitest"; -import {buildSystemPrompt} from "../../src/agent/context.js"; -import type {AgentRunContext} from "../../src/agent/index.js"; +import {Agent} from "../../src/agent/index.js"; +import {BaseLLMClient} from "../../src/llm/index.js"; +import type {LLMGenerateInput, LLMResponse} from "../../src/llm/types.js"; +import type {WorkspaceProfile} from "../../src/runtime/index.js"; -describe("buildSystemPrompt", () => { - it("adds CLI markdown output instructions", () => { - const prompt = buildSystemPrompt( - { - input: {prompt: "test"}, - config: { - runtime: { - systemPrompt: "Base prompt.", - workspaceDir: ".", - maxIterations: 1, - maxRepairAttempts: 0, - tokensLimit: 1000, - rollbackOnFailure: false, - }, +class CapturingLLMClient extends BaseLLMClient { + request: LLMGenerateInput | undefined; + + override async generate(input: LLMGenerateInput): Promise { + this.request = input; + return {content: "Done.", toolCalls: []}; + } +} + +describe("ContextManager system prompt", () => { + it("adds CLI markdown output instructions", async () => { + const workspaceRoot = process.cwd(); + const llm = new CapturingLLMClient(); + const profile: WorkspaceProfile = { + root: workspaceRoot, + packageManager: "pnpm", + scripts: {}, + projectFiles: [], + detectedFrameworks: [], + }; + + await new Agent({ + config: { + runtime: { + systemPrompt: "Base prompt.", + workspaceDir: workspaceRoot, + maxIterations: 1, + maxRepairAttempts: 0, + tokensLimit: 1000, + rollbackOnFailure: false, + }, + permissions: { + readFile: true, + writeFile: false, + network: false, + shell: false, }, - } as AgentRunContext, - "", - ); + verification: { + enabled: false, + commands: [], + }, + trace: { + enabled: false, + directory: workspaceRoot, + }, + }, + llm, + workspaceScanner: { + async scan(): Promise { + return profile; + }, + }, + }).run({prompt: "test"}); + + const prompt = llm.request?.messages[0]?.content; expect(prompt).toContain("Base prompt."); expect(prompt).toContain("Do not use Markdown tables");