From f4fd4f9910d7dc4fd51abca154709af1e61d2dc6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 25 May 2026 02:01:28 +0100 Subject: [PATCH] refactor: extract agent core package Introduce packages/agent-core as the OpenClaw-owned home for reusable agent loop, harness, session, prompt, and runtime dependency contracts. --- packages/agent-core/package.json | 107 ++ packages/agent-core/src/agent-loop.ts | 782 +++++++++++ packages/agent-core/src/agent.ts | 589 ++++++++ .../agent-core/src/harness/agent-harness.ts | 1184 +++++++++++++++++ .../compaction/branch-summarization.ts | 290 ++++ .../src/harness/compaction/compaction.ts | 817 ++++++++++++ .../src/harness/compaction/utils.ts | 167 +++ packages/agent-core/src/harness/env/nodejs.ts | 622 +++++++++ packages/agent-core/src/harness/messages.ts | 179 +++ .../src/harness/prompt-templates.ts | 319 +++++ .../src/harness/session/jsonl-repo.ts | 197 +++ .../src/harness/session/jsonl-storage.ts | 349 +++++ .../src/harness/session/memory-repo.ts | 50 + .../src/harness/session/memory-storage.ts | 148 +++ .../src/harness/session/repo-utils.ts | 61 + .../agent-core/src/harness/session/session.ts | 270 ++++ .../agent-core/src/harness/session/uuid.ts | 54 + packages/agent-core/src/harness/skills.ts | 463 +++++++ .../agent-core/src/harness/system-prompt.ts | 36 + packages/agent-core/src/harness/types.ts | 850 ++++++++++++ .../src/harness/utils/shell-output.ts | 174 +++ .../agent-core/src/harness/utils/truncate.ts | 361 +++++ packages/agent-core/src/index.ts | 41 + packages/agent-core/src/node.ts | 2 + packages/agent-core/src/types.ts | 439 ++++++ src/agents/runtime/index.ts | 4 + src/agents/runtime/proxy.ts | 380 ++++++ tsconfig.json | 2 + tsdown.config.ts | 65 +- 29 files changed, 8994 insertions(+), 8 deletions(-) create mode 100644 packages/agent-core/package.json create mode 100644 packages/agent-core/src/agent-loop.ts create mode 100644 packages/agent-core/src/agent.ts create mode 100644 packages/agent-core/src/harness/agent-harness.ts create mode 100644 packages/agent-core/src/harness/compaction/branch-summarization.ts create mode 100644 packages/agent-core/src/harness/compaction/compaction.ts create mode 100644 packages/agent-core/src/harness/compaction/utils.ts create mode 100644 packages/agent-core/src/harness/env/nodejs.ts create mode 100644 packages/agent-core/src/harness/messages.ts create mode 100644 packages/agent-core/src/harness/prompt-templates.ts create mode 100644 packages/agent-core/src/harness/session/jsonl-repo.ts create mode 100644 packages/agent-core/src/harness/session/jsonl-storage.ts create mode 100644 packages/agent-core/src/harness/session/memory-repo.ts create mode 100644 packages/agent-core/src/harness/session/memory-storage.ts create mode 100644 packages/agent-core/src/harness/session/repo-utils.ts create mode 100644 packages/agent-core/src/harness/session/session.ts create mode 100644 packages/agent-core/src/harness/session/uuid.ts create mode 100644 packages/agent-core/src/harness/skills.ts create mode 100644 packages/agent-core/src/harness/system-prompt.ts create mode 100644 packages/agent-core/src/harness/types.ts create mode 100644 packages/agent-core/src/harness/utils/shell-output.ts create mode 100644 packages/agent-core/src/harness/utils/truncate.ts create mode 100644 packages/agent-core/src/index.ts create mode 100644 packages/agent-core/src/node.ts create mode 100644 packages/agent-core/src/types.ts create mode 100644 src/agents/runtime/index.ts create mode 100644 src/agents/runtime/proxy.ts diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json new file mode 100644 index 00000000000..619d149be30 --- /dev/null +++ b/packages/agent-core/package.json @@ -0,0 +1,107 @@ +{ + "name": "@openclaw/agent-core", + "version": "0.0.0-private", + "private": true, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./agent": { + "types": "./dist/agent.d.ts", + "default": "./dist/agent.js" + }, + "./agent-loop": { + "types": "./dist/agent-loop.d.ts", + "default": "./dist/agent-loop.js" + }, + "./node": { + "types": "./dist/node.d.ts", + "default": "./dist/node.js" + }, + "./types": { + "types": "./dist/types.d.ts", + "default": "./dist/types.js" + }, + "./harness/agent-harness": { + "types": "./dist/harness/agent-harness.d.ts", + "default": "./dist/harness/agent-harness.js" + }, + "./harness/types": { + "types": "./dist/harness/types.d.ts", + "default": "./dist/harness/types.js" + }, + "./harness/messages": { + "types": "./dist/harness/messages.d.ts", + "default": "./dist/harness/messages.js" + }, + "./harness/session": { + "types": "./dist/harness/session.d.ts", + "default": "./dist/harness/session.js" + }, + "./harness/session/jsonl-repo": { + "types": "./dist/harness/session/jsonl-repo.d.ts", + "default": "./dist/harness/session/jsonl-repo.js" + }, + "./harness/session/jsonl-storage": { + "types": "./dist/harness/session/jsonl-storage.d.ts", + "default": "./dist/harness/session/jsonl-storage.js" + }, + "./harness/session/memory-repo": { + "types": "./dist/harness/session/memory-repo.d.ts", + "default": "./dist/harness/session/memory-repo.js" + }, + "./harness/session/memory-storage": { + "types": "./dist/harness/session/memory-storage.d.ts", + "default": "./dist/harness/session/memory-storage.js" + }, + "./harness/session/repo-utils": { + "types": "./dist/harness/session/repo-utils.d.ts", + "default": "./dist/harness/session/repo-utils.js" + }, + "./harness/session/uuid": { + "types": "./dist/harness/session/uuid.d.ts", + "default": "./dist/harness/session/uuid.js" + }, + "./harness/compaction": { + "types": "./dist/harness/compaction.d.ts", + "default": "./dist/harness/compaction.js" + }, + "./harness/branch-summarization": { + "types": "./dist/harness/branch-summarization.d.ts", + "default": "./dist/harness/branch-summarization.js" + }, + "./harness/prompt-templates": { + "types": "./dist/harness/prompt-templates.d.ts", + "default": "./dist/harness/prompt-templates.js" + }, + "./harness/skills": { + "types": "./dist/harness/skills.d.ts", + "default": "./dist/harness/skills.js" + }, + "./harness/system-prompt": { + "types": "./dist/harness/system-prompt.d.ts", + "default": "./dist/harness/system-prompt.js" + }, + "./harness/utils/shell-output": { + "types": "./dist/harness/utils/shell-output.d.ts", + "default": "./dist/harness/utils/shell-output.js" + }, + "./harness/utils/truncate": { + "types": "./dist/harness/utils/truncate.d.ts", + "default": "./dist/harness/utils/truncate.js" + } + }, + "dependencies": { + "ignore": "7.0.5", + "openclaw": "workspace:*", + "typebox": "1.1.38", + "yaml": "2.9.0" + } +} diff --git a/packages/agent-core/src/agent-loop.ts b/packages/agent-core/src/agent-loop.ts new file mode 100644 index 00000000000..c8eb14aa34c --- /dev/null +++ b/packages/agent-core/src/agent-loop.ts @@ -0,0 +1,782 @@ +/** + * Agent loop that works with AgentMessage throughout. + * Transforms to Message[] only at the LLM call boundary. + */ + +import { + type AssistantMessage, + type Context, + EventStream, + streamSimple, + type ToolResultMessage, + validateToolArguments, +} from "openclaw/plugin-sdk/llm"; +import type { + AgentContext, + AgentEvent, + AgentLoopConfig, + AgentMessage, + AgentTool, + AgentToolCall, + AgentToolResult, + StreamFn, +} from "./types.js"; + +export type AgentEventSink = (event: AgentEvent) => Promise | void; + +/** + * Start an agent loop with a new prompt message. + * The prompt is added to the context and events are emitted for it. + */ +export function agentLoop( + prompts: AgentMessage[], + context: AgentContext, + config: AgentLoopConfig, + signal?: AbortSignal, + streamFn?: StreamFn, +): EventStream { + const stream = createAgentStream(); + + void runAgentLoop( + prompts, + context, + config, + async (event) => { + stream.push(event); + }, + signal, + streamFn, + ).then((messages) => { + stream.end(messages); + }); + + return stream; +} + +/** + * Continue an agent loop from the current context without adding a new message. + * Used for retries - context already has user message or tool results. + * + * **Important:** The last message in context must convert to a `user` or `toolResult` message + * via `convertToLlm`. If it doesn't, the LLM provider will reject the request. + * This cannot be validated here since `convertToLlm` is only called once per turn. + */ +export function agentLoopContinue( + context: AgentContext, + config: AgentLoopConfig, + signal?: AbortSignal, + streamFn?: StreamFn, +): EventStream { + if (context.messages.length === 0) { + throw new Error("Cannot continue: no messages in context"); + } + + if (context.messages[context.messages.length - 1].role === "assistant") { + throw new Error("Cannot continue from message role: assistant"); + } + + const stream = createAgentStream(); + + void runAgentLoopContinue( + context, + config, + async (event) => { + stream.push(event); + }, + signal, + streamFn, + ).then((messages) => { + stream.end(messages); + }); + + return stream; +} + +export async function runAgentLoop( + prompts: AgentMessage[], + context: AgentContext, + config: AgentLoopConfig, + emit: AgentEventSink, + signal?: AbortSignal, + streamFn?: StreamFn, +): Promise { + const newMessages: AgentMessage[] = [...prompts]; + const currentContext: AgentContext = { + ...context, + messages: [...context.messages, ...prompts], + }; + + await emit({ type: "agent_start" }); + await emit({ type: "turn_start" }); + for (const prompt of prompts) { + await emit({ type: "message_start", message: prompt }); + await emit({ type: "message_end", message: prompt }); + } + + await runLoop(currentContext, newMessages, config, signal, emit, streamFn); + return newMessages; +} + +export async function runAgentLoopContinue( + context: AgentContext, + config: AgentLoopConfig, + emit: AgentEventSink, + signal?: AbortSignal, + streamFn?: StreamFn, +): Promise { + if (context.messages.length === 0) { + throw new Error("Cannot continue: no messages in context"); + } + + if (context.messages[context.messages.length - 1].role === "assistant") { + throw new Error("Cannot continue from message role: assistant"); + } + + const newMessages: AgentMessage[] = []; + const currentContext: AgentContext = { ...context }; + + await emit({ type: "agent_start" }); + await emit({ type: "turn_start" }); + + await runLoop(currentContext, newMessages, config, signal, emit, streamFn); + return newMessages; +} + +function createAgentStream(): EventStream { + return new EventStream( + (event: AgentEvent) => event.type === "agent_end", + (event: AgentEvent) => (event.type === "agent_end" ? event.messages : []), + ); +} + +/** + * Main loop logic shared by agentLoop and agentLoopContinue. + */ +async function runLoop( + initialContext: AgentContext, + newMessages: AgentMessage[], + initialConfig: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, + streamFn?: StreamFn, +): Promise { + let currentContext = initialContext; + let config = initialConfig; + let firstTurn = true; + // Check for steering messages at start (user may have typed while waiting) + let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || []; + + // Outer loop: continues when queued follow-up messages arrive after agent would stop + while (true) { + let hasMoreToolCalls = true; + + // Inner loop: process tool calls and steering messages + while (hasMoreToolCalls || pendingMessages.length > 0) { + if (!firstTurn) { + await emit({ type: "turn_start" }); + } else { + firstTurn = false; + } + + // Process pending messages (inject before next assistant response) + if (pendingMessages.length > 0) { + for (const message of pendingMessages) { + await emit({ type: "message_start", message }); + await emit({ type: "message_end", message }); + currentContext.messages.push(message); + newMessages.push(message); + } + pendingMessages = []; + } + + // Stream assistant response + const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn); + newMessages.push(message); + + if (message.stopReason === "error" || message.stopReason === "aborted") { + await emit({ type: "turn_end", message, toolResults: [] }); + await emit({ type: "agent_end", messages: newMessages }); + return; + } + + // Check for tool calls + const toolCalls = message.content.filter((c) => c.type === "toolCall"); + + const toolResults: ToolResultMessage[] = []; + hasMoreToolCalls = false; + if (toolCalls.length > 0) { + const executedToolBatch = await executeToolCalls( + currentContext, + message, + config, + signal, + emit, + ); + toolResults.push(...executedToolBatch.messages); + hasMoreToolCalls = !executedToolBatch.terminate; + + for (const result of toolResults) { + currentContext.messages.push(result); + newMessages.push(result); + } + } + + await emit({ type: "turn_end", message, toolResults }); + + const nextTurnContext = { + message, + toolResults, + context: currentContext, + newMessages, + }; + const nextTurnSnapshot = await config.prepareNextTurn?.(nextTurnContext); + if (nextTurnSnapshot) { + currentContext = nextTurnSnapshot.context ?? currentContext; + config = Object.assign({}, config, { + model: nextTurnSnapshot.model ?? config.model, + reasoning: + nextTurnSnapshot.thinkingLevel === undefined + ? config.reasoning + : nextTurnSnapshot.thinkingLevel === "off" + ? undefined + : nextTurnSnapshot.thinkingLevel, + }); + } + + if ( + await config.shouldStopAfterTurn?.({ + message, + toolResults, + context: currentContext, + newMessages, + }) + ) { + await emit({ type: "agent_end", messages: newMessages }); + return; + } + + pendingMessages = (await config.getSteeringMessages?.()) || []; + } + + // Agent would stop here. Check for follow-up messages. + const followUpMessages = (await config.getFollowUpMessages?.()) || []; + if (followUpMessages.length > 0) { + // Set as pending so inner loop processes them + pendingMessages = followUpMessages; + continue; + } + + // No more messages, exit + break; + } + + await emit({ type: "agent_end", messages: newMessages }); +} + +/** + * Stream an assistant response from the LLM. + * This is where AgentMessage[] gets transformed to Message[] for the LLM. + */ +async function streamAssistantResponse( + context: AgentContext, + config: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, + streamFn?: StreamFn, +): Promise { + // Apply context transform if configured (AgentMessage[] → AgentMessage[]) + let messages = context.messages; + if (config.transformContext) { + messages = await config.transformContext(messages, signal); + } + + // Convert to LLM-compatible messages (AgentMessage[] → Message[]) + const llmMessages = await config.convertToLlm(messages); + + // Build LLM context + const llmContext: Context = { + systemPrompt: context.systemPrompt, + messages: llmMessages, + tools: context.tools, + }; + + const streamFunction = streamFn || streamSimple; + + // Resolve API key (important for expiring tokens) + const resolvedApiKey = + (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey; + + const response = await streamFunction(config.model, llmContext, { + ...config, + apiKey: resolvedApiKey, + signal, + }); + + let partialMessage: AssistantMessage | null = null; + let addedPartial = false; + + for await (const event of response) { + switch (event.type) { + case "start": + partialMessage = event.partial; + context.messages.push(partialMessage); + addedPartial = true; + await emit({ type: "message_start", message: { ...partialMessage } }); + break; + + case "text_start": + case "text_delta": + case "text_end": + case "thinking_start": + case "thinking_delta": + case "thinking_end": + case "toolcall_start": + case "toolcall_delta": + case "toolcall_end": + if (partialMessage) { + partialMessage = event.partial; + context.messages[context.messages.length - 1] = partialMessage; + await emit({ + type: "message_update", + assistantMessageEvent: event, + message: { ...partialMessage }, + }); + } + break; + + case "done": + case "error": { + const finalMessage = await response.result(); + if (addedPartial) { + context.messages[context.messages.length - 1] = finalMessage; + } else { + context.messages.push(finalMessage); + } + if (!addedPartial) { + await emit({ type: "message_start", message: { ...finalMessage } }); + } + await emit({ type: "message_end", message: finalMessage }); + return finalMessage; + } + } + } + + const finalMessage = await response.result(); + if (addedPartial) { + context.messages[context.messages.length - 1] = finalMessage; + } else { + context.messages.push(finalMessage); + await emit({ type: "message_start", message: { ...finalMessage } }); + } + await emit({ type: "message_end", message: finalMessage }); + return finalMessage; +} + +/** + * Execute tool calls from an assistant message. + */ +async function executeToolCalls( + currentContext: AgentContext, + assistantMessage: AssistantMessage, + config: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, +): Promise { + const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall"); + const hasSequentialToolCall = toolCalls.some( + (tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === "sequential", + ); + if (config.toolExecution === "sequential" || hasSequentialToolCall) { + return executeToolCallsSequential( + currentContext, + assistantMessage, + toolCalls, + config, + signal, + emit, + ); + } + return executeToolCallsParallel( + currentContext, + assistantMessage, + toolCalls, + config, + signal, + emit, + ); +} + +type ExecutedToolCallBatch = { + messages: ToolResultMessage[]; + terminate: boolean; +}; + +async function executeToolCallsSequential( + currentContext: AgentContext, + assistantMessage: AssistantMessage, + toolCalls: AgentToolCall[], + config: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, +): Promise { + const finalizedCalls: FinalizedToolCallOutcome[] = []; + const messages: ToolResultMessage[] = []; + + for (const toolCall of toolCalls) { + await emit({ + type: "tool_execution_start", + toolCallId: toolCall.id, + toolName: toolCall.name, + args: toolCall.arguments, + }); + + const preparation = await prepareToolCall( + currentContext, + assistantMessage, + toolCall, + config, + signal, + ); + let finalized: FinalizedToolCallOutcome; + if (preparation.kind === "immediate") { + finalized = { + toolCall, + result: preparation.result, + isError: preparation.isError, + }; + } else { + const executed = await executePreparedToolCall(preparation, signal, emit); + finalized = await finalizeExecutedToolCall( + currentContext, + assistantMessage, + preparation, + executed, + config, + signal, + ); + } + + await emitToolExecutionEnd(finalized, emit); + const toolResultMessage = createToolResultMessage(finalized); + await emitToolResultMessage(toolResultMessage, emit); + finalizedCalls.push(finalized); + messages.push(toolResultMessage); + + if (signal?.aborted) { + break; + } + } + + return { + messages, + terminate: shouldTerminateToolBatch(finalizedCalls), + }; +} + +async function executeToolCallsParallel( + currentContext: AgentContext, + assistantMessage: AssistantMessage, + toolCalls: AgentToolCall[], + config: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, +): Promise { + const finalizedCalls: FinalizedToolCallEntry[] = []; + + for (const toolCall of toolCalls) { + await emit({ + type: "tool_execution_start", + toolCallId: toolCall.id, + toolName: toolCall.name, + args: toolCall.arguments, + }); + + const preparation = await prepareToolCall( + currentContext, + assistantMessage, + toolCall, + config, + signal, + ); + if (preparation.kind === "immediate") { + const finalized = { + toolCall, + result: preparation.result, + isError: preparation.isError, + } satisfies FinalizedToolCallOutcome; + await emitToolExecutionEnd(finalized, emit); + finalizedCalls.push(finalized); + if (signal?.aborted) { + break; + } + continue; + } + + finalizedCalls.push(async () => { + const executed = await executePreparedToolCall(preparation, signal, emit); + const finalized = await finalizeExecutedToolCall( + currentContext, + assistantMessage, + preparation, + executed, + config, + signal, + ); + await emitToolExecutionEnd(finalized, emit); + return finalized; + }); + if (signal?.aborted) { + break; + } + } + + const orderedFinalizedCalls = await Promise.all( + finalizedCalls.map((entry) => (typeof entry === "function" ? entry() : Promise.resolve(entry))), + ); + const messages: ToolResultMessage[] = []; + for (const finalized of orderedFinalizedCalls) { + const toolResultMessage = createToolResultMessage(finalized); + await emitToolResultMessage(toolResultMessage, emit); + messages.push(toolResultMessage); + } + + return { + messages, + terminate: shouldTerminateToolBatch(orderedFinalizedCalls), + }; +} + +type PreparedToolCall = { + kind: "prepared"; + toolCall: AgentToolCall; + tool: AgentTool; + args: unknown; +}; + +type ImmediateToolCallOutcome = { + kind: "immediate"; + result: AgentToolResult; + isError: boolean; +}; + +type ExecutedToolCallOutcome = { + result: AgentToolResult; + isError: boolean; +}; + +type FinalizedToolCallOutcome = { + toolCall: AgentToolCall; + result: AgentToolResult; + isError: boolean; +}; + +type FinalizedToolCallEntry = FinalizedToolCallOutcome | (() => Promise); + +function shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]): boolean { + return ( + finalizedCalls.length > 0 && + finalizedCalls.every((finalized) => finalized.result.terminate === true) + ); +} + +function prepareToolCallArguments(tool: AgentTool, toolCall: AgentToolCall): AgentToolCall { + if (!tool.prepareArguments) { + return toolCall; + } + const preparedArguments = tool.prepareArguments(toolCall.arguments); + if (preparedArguments === toolCall.arguments) { + return toolCall; + } + return { + ...toolCall, + arguments: preparedArguments as Record, + }; +} + +async function prepareToolCall( + currentContext: AgentContext, + assistantMessage: AssistantMessage, + toolCall: AgentToolCall, + config: AgentLoopConfig, + signal: AbortSignal | undefined, +): Promise { + const tool = currentContext.tools?.find((t) => t.name === toolCall.name); + if (!tool) { + return { + kind: "immediate", + result: createErrorToolResult(`Tool ${toolCall.name} not found`), + isError: true, + }; + } + + try { + const preparedToolCall = prepareToolCallArguments(tool, toolCall); + const validatedArgs = validateToolArguments(tool, preparedToolCall); + if (config.beforeToolCall) { + const beforeResult = await config.beforeToolCall( + { + assistantMessage, + toolCall, + args: validatedArgs, + context: currentContext, + }, + signal, + ); + if (signal?.aborted) { + return { + kind: "immediate", + result: createErrorToolResult("Operation aborted"), + isError: true, + }; + } + if (beforeResult?.block) { + return { + kind: "immediate", + result: createErrorToolResult(beforeResult.reason || "Tool execution was blocked"), + isError: true, + }; + } + } + if (signal?.aborted) { + return { + kind: "immediate", + result: createErrorToolResult("Operation aborted"), + isError: true, + }; + } + return { + kind: "prepared", + toolCall, + tool, + args: validatedArgs, + }; + } catch (error) { + return { + kind: "immediate", + result: createErrorToolResult(error instanceof Error ? error.message : String(error)), + isError: true, + }; + } +} + +async function executePreparedToolCall( + prepared: PreparedToolCall, + signal: AbortSignal | undefined, + emit: AgentEventSink, +): Promise { + const updateEvents: Promise[] = []; + + try { + const result = await prepared.tool.execute( + prepared.toolCall.id, + prepared.args as never, + signal, + (partialResult) => { + updateEvents.push( + Promise.resolve( + emit({ + type: "tool_execution_update", + toolCallId: prepared.toolCall.id, + toolName: prepared.toolCall.name, + args: prepared.toolCall.arguments, + partialResult, + }), + ), + ); + }, + ); + await Promise.all(updateEvents); + return { result, isError: false }; + } catch (error) { + await Promise.all(updateEvents); + return { + result: createErrorToolResult(error instanceof Error ? error.message : String(error)), + isError: true, + }; + } +} + +async function finalizeExecutedToolCall( + currentContext: AgentContext, + assistantMessage: AssistantMessage, + prepared: PreparedToolCall, + executed: ExecutedToolCallOutcome, + config: AgentLoopConfig, + signal: AbortSignal | undefined, +): Promise { + let result = executed.result; + let isError = executed.isError; + + if (config.afterToolCall) { + try { + const afterResult = await config.afterToolCall( + { + assistantMessage, + toolCall: prepared.toolCall, + args: prepared.args, + result, + isError, + context: currentContext, + }, + signal, + ); + if (afterResult) { + result = { + content: afterResult.content ?? result.content, + details: afterResult.details ?? result.details, + terminate: afterResult.terminate ?? result.terminate, + }; + isError = afterResult.isError ?? isError; + } + } catch (error) { + result = createErrorToolResult(error instanceof Error ? error.message : String(error)); + isError = true; + } + } + + return { + toolCall: prepared.toolCall, + result, + isError, + }; +} + +function createErrorToolResult(message: string): AgentToolResult { + return { + content: [{ type: "text", text: message }], + details: {}, + }; +} + +async function emitToolExecutionEnd( + finalized: FinalizedToolCallOutcome, + emit: AgentEventSink, +): Promise { + await emit({ + type: "tool_execution_end", + toolCallId: finalized.toolCall.id, + toolName: finalized.toolCall.name, + result: finalized.result, + isError: finalized.isError, + }); +} + +function createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResultMessage { + return { + role: "toolResult", + toolCallId: finalized.toolCall.id, + toolName: finalized.toolCall.name, + content: finalized.result.content, + details: finalized.result.details, + isError: finalized.isError, + timestamp: Date.now(), + }; +} + +async function emitToolResultMessage( + toolResultMessage: ToolResultMessage, + emit: AgentEventSink, +): Promise { + await emit({ type: "message_start", message: toolResultMessage }); + await emit({ type: "message_end", message: toolResultMessage }); +} diff --git a/packages/agent-core/src/agent.ts b/packages/agent-core/src/agent.ts new file mode 100644 index 00000000000..cccb91365f0 --- /dev/null +++ b/packages/agent-core/src/agent.ts @@ -0,0 +1,589 @@ +import { + type ImageContent, + type Message, + type Model, + type SimpleStreamOptions, + streamSimple, + type TextContent, + type ThinkingBudgets, + type Transport, +} from "openclaw/plugin-sdk/llm"; +import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.js"; +import type { + AfterToolCallContext, + AfterToolCallResult, + AgentContext, + AgentEvent, + AgentLoopConfig, + AgentLoopTurnUpdate, + AgentMessage, + AgentState, + AgentTool, + BeforeToolCallContext, + BeforeToolCallResult, + QueueMode, + StreamFn, + ToolExecutionMode, +} from "./types.js"; + +export type { QueueMode } from "./types.js"; + +function defaultConvertToLlm(messages: AgentMessage[]): Message[] { + return messages.filter( + (message) => + message.role === "user" || message.role === "assistant" || message.role === "toolResult", + ); +} + +const EMPTY_USAGE = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +const DEFAULT_MODEL = { + id: "unknown", + name: "unknown", + api: "unknown", + provider: "unknown", + baseUrl: "", + reasoning: false, + input: [], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 0, + maxTokens: 0, +} satisfies Model; + +type MutableAgentState = Omit< + AgentState, + "isStreaming" | "streamingMessage" | "pendingToolCalls" | "errorMessage" +> & { + isStreaming: boolean; + streamingMessage?: AgentMessage; + pendingToolCalls: Set; + errorMessage?: string; +}; + +function createMutableAgentState( + initialState?: Partial< + Omit + >, +): MutableAgentState { + let tools = initialState?.tools?.slice() ?? []; + let messages = initialState?.messages?.slice() ?? []; + + return { + systemPrompt: initialState?.systemPrompt ?? "", + model: initialState?.model ?? DEFAULT_MODEL, + thinkingLevel: initialState?.thinkingLevel ?? "off", + get tools() { + return tools; + }, + set tools(nextTools: AgentTool[]) { + tools = nextTools.slice(); + }, + get messages() { + return messages; + }, + set messages(nextMessages: AgentMessage[]) { + messages = nextMessages.slice(); + }, + isStreaming: false, + streamingMessage: undefined, + pendingToolCalls: new Set(), + errorMessage: undefined, + }; +} + +/** Options for constructing an {@link Agent}. */ +export interface AgentOptions { + initialState?: Partial< + Omit + >; + convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise; + transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; + streamFn?: StreamFn; + getApiKey?: (provider: string) => Promise | string | undefined; + onPayload?: SimpleStreamOptions["onPayload"]; + onResponse?: SimpleStreamOptions["onResponse"]; + beforeToolCall?: ( + context: BeforeToolCallContext, + signal?: AbortSignal, + ) => Promise; + afterToolCall?: ( + context: AfterToolCallContext, + signal?: AbortSignal, + ) => Promise; + prepareNextTurn?: ( + signal?: AbortSignal, + ) => Promise | AgentLoopTurnUpdate | undefined; + steeringMode?: QueueMode; + followUpMode?: QueueMode; + sessionId?: string; + thinkingBudgets?: ThinkingBudgets; + transport?: Transport; + maxRetryDelayMs?: number; + toolExecution?: ToolExecutionMode; +} + +class PendingMessageQueue { + private messages: AgentMessage[] = []; + public mode: QueueMode; + + constructor(mode: QueueMode) { + this.mode = mode; + } + + enqueue(message: AgentMessage): void { + this.messages.push(message); + } + + hasItems(): boolean { + return this.messages.length > 0; + } + + drain(): AgentMessage[] { + if (this.mode === "all") { + const drained = this.messages.slice(); + this.messages = []; + return drained; + } + + const first = this.messages[0]; + if (!first) { + return []; + } + this.messages = this.messages.slice(1); + return [first]; + } + + clear(): void { + this.messages = []; + } +} + +type ActiveRun = { + promise: Promise; + resolve: () => void; + abortController: AbortController; +}; + +/** + * Stateful wrapper around the low-level agent loop. + * + * `Agent` owns the current transcript, emits lifecycle events, executes tools, + * and exposes queueing APIs for steering and follow-up messages. + */ +export class Agent { + private mutableState: MutableAgentState; + private readonly listeners = new Set< + (event: AgentEvent, signal: AbortSignal) => Promise | void + >(); + private readonly steeringQueue: PendingMessageQueue; + private readonly followUpQueue: PendingMessageQueue; + + public convertToLlm: (messages: AgentMessage[]) => Message[] | Promise; + public transformContext?: ( + messages: AgentMessage[], + signal?: AbortSignal, + ) => Promise; + public streamFn: StreamFn; + public getApiKey?: (provider: string) => Promise | string | undefined; + public onPayload?: SimpleStreamOptions["onPayload"]; + public onResponse?: SimpleStreamOptions["onResponse"]; + public beforeToolCall?: ( + context: BeforeToolCallContext, + signal?: AbortSignal, + ) => Promise; + public afterToolCall?: ( + context: AfterToolCallContext, + signal?: AbortSignal, + ) => Promise; + public prepareNextTurn?: ( + signal?: AbortSignal, + ) => Promise | AgentLoopTurnUpdate | undefined; + private activeRun?: ActiveRun; + /** Session identifier forwarded to providers for cache-aware backends. */ + public sessionId?: string; + /** Optional per-level thinking token budgets forwarded to the stream function. */ + public thinkingBudgets?: ThinkingBudgets; + /** Preferred transport forwarded to the stream function. */ + public transport: Transport; + /** Optional cap for provider-requested retry delays. */ + public maxRetryDelayMs?: number; + /** Tool execution strategy for assistant messages that contain multiple tool calls. */ + public toolExecution: ToolExecutionMode; + + constructor(options: AgentOptions = {}) { + this.mutableState = createMutableAgentState(options.initialState); + this.convertToLlm = options.convertToLlm ?? defaultConvertToLlm; + this.transformContext = options.transformContext; + this.streamFn = options.streamFn ?? streamSimple; + this.getApiKey = options.getApiKey; + this.onPayload = options.onPayload; + this.onResponse = options.onResponse; + this.beforeToolCall = options.beforeToolCall; + this.afterToolCall = options.afterToolCall; + this.prepareNextTurn = options.prepareNextTurn; + this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time"); + this.followUpQueue = new PendingMessageQueue(options.followUpMode ?? "one-at-a-time"); + this.sessionId = options.sessionId; + this.thinkingBudgets = options.thinkingBudgets; + this.transport = options.transport ?? "auto"; + this.maxRetryDelayMs = options.maxRetryDelayMs; + this.toolExecution = options.toolExecution ?? "parallel"; + } + + /** + * Subscribe to agent lifecycle events. + * + * Listener promises are awaited in subscription order and are included in + * the current run's settlement. Listeners also receive the active abort + * signal for the current run. + * + * `agent_end` is the final emitted event for a run, but the agent does not + * become idle until all awaited listeners for that event have settled. + */ + subscribe( + listener: (event: AgentEvent, signal: AbortSignal) => Promise | void, + ): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** + * Current agent state. + * + * Assigning `state.tools` or `state.messages` copies the provided top-level array. + */ + get state(): AgentState { + return this.mutableState; + } + + /** Controls how queued steering messages are drained. */ + set steeringMode(mode: QueueMode) { + this.steeringQueue.mode = mode; + } + + get steeringMode(): QueueMode { + return this.steeringQueue.mode; + } + + /** Controls how queued follow-up messages are drained. */ + set followUpMode(mode: QueueMode) { + this.followUpQueue.mode = mode; + } + + get followUpMode(): QueueMode { + return this.followUpQueue.mode; + } + + /** Queue a message to be injected after the current assistant turn finishes. */ + steer(message: AgentMessage): void { + this.steeringQueue.enqueue(message); + } + + /** Queue a message to run only after the agent would otherwise stop. */ + followUp(message: AgentMessage): void { + this.followUpQueue.enqueue(message); + } + + /** Remove all queued steering messages. */ + clearSteeringQueue(): void { + this.steeringQueue.clear(); + } + + /** Remove all queued follow-up messages. */ + clearFollowUpQueue(): void { + this.followUpQueue.clear(); + } + + /** Remove all queued steering and follow-up messages. */ + clearAllQueues(): void { + this.clearSteeringQueue(); + this.clearFollowUpQueue(); + } + + /** Returns true when either queue still contains pending messages. */ + hasQueuedMessages(): boolean { + return this.steeringQueue.hasItems() || this.followUpQueue.hasItems(); + } + + /** Active abort signal for the current run, if any. */ + get signal(): AbortSignal | undefined { + return this.activeRun?.abortController.signal; + } + + /** Abort the current run, if one is active. */ + abort(): void { + this.activeRun?.abortController.abort(); + } + + /** + * Resolve when the current run and all awaited event listeners have finished. + * + * This resolves after `agent_end` listeners settle. + */ + waitForIdle(): Promise { + return this.activeRun?.promise ?? Promise.resolve(); + } + + /** Clear transcript state, runtime state, and queued messages. */ + reset(): void { + this.mutableState.messages = []; + this.mutableState.isStreaming = false; + this.mutableState.streamingMessage = undefined; + this.mutableState.pendingToolCalls = new Set(); + this.mutableState.errorMessage = undefined; + this.clearFollowUpQueue(); + this.clearSteeringQueue(); + } + + /** Start a new prompt from text, a single message, or a batch of messages. */ + async prompt(message: AgentMessage | AgentMessage[]): Promise; + async prompt(input: string, images?: ImageContent[]): Promise; + async prompt( + input: string | AgentMessage | AgentMessage[], + images?: ImageContent[], + ): Promise { + if (this.activeRun) { + throw new Error( + "Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.", + ); + } + const messages = this.normalizePromptInput(input, images); + await this.runPromptMessages(messages); + } + + /** Continue from the current transcript. The last message must be a user or tool-result message. */ + async continue(): Promise { + if (this.activeRun) { + throw new Error("Agent is already processing. Wait for completion before continuing."); + } + + const lastMessage = this.mutableState.messages[this.mutableState.messages.length - 1]; + if (!lastMessage) { + throw new Error("No messages to continue from"); + } + + if (lastMessage.role === "assistant") { + const queuedSteering = this.steeringQueue.drain(); + if (queuedSteering.length > 0) { + await this.runPromptMessages(queuedSteering, { skipInitialSteeringPoll: true }); + return; + } + + const queuedFollowUps = this.followUpQueue.drain(); + if (queuedFollowUps.length > 0) { + await this.runPromptMessages(queuedFollowUps); + return; + } + + throw new Error("Cannot continue from message role: assistant"); + } + + await this.runContinuation(); + } + + private normalizePromptInput( + input: string | AgentMessage | AgentMessage[], + images?: ImageContent[], + ): AgentMessage[] { + if (Array.isArray(input)) { + return input; + } + + if (typeof input !== "string") { + return [input]; + } + + const content: Array = [{ type: "text", text: input }]; + if (images && images.length > 0) { + content.push(...images); + } + return [{ role: "user", content, timestamp: Date.now() }]; + } + + private async runPromptMessages( + messages: AgentMessage[], + options: { skipInitialSteeringPoll?: boolean } = {}, + ): Promise { + await this.runWithLifecycle(async (signal) => { + await runAgentLoop( + messages, + this.createContextSnapshot(), + this.createLoopConfig(options), + (event) => this.processEvents(event), + signal, + this.streamFn, + ); + }); + } + + private async runContinuation(): Promise { + await this.runWithLifecycle(async (signal) => { + await runAgentLoopContinue( + this.createContextSnapshot(), + this.createLoopConfig(), + (event) => this.processEvents(event), + signal, + this.streamFn, + ); + }); + } + + private createContextSnapshot(): AgentContext { + return { + systemPrompt: this.mutableState.systemPrompt, + messages: this.mutableState.messages.slice(), + tools: this.mutableState.tools.slice(), + }; + } + + private createLoopConfig(options: { skipInitialSteeringPoll?: boolean } = {}): AgentLoopConfig { + let skipInitialSteeringPoll = options.skipInitialSteeringPoll === true; + return { + model: this.mutableState.model, + reasoning: + this.mutableState.thinkingLevel === "off" ? undefined : this.mutableState.thinkingLevel, + sessionId: this.sessionId, + onPayload: this.onPayload, + onResponse: this.onResponse, + transport: this.transport, + thinkingBudgets: this.thinkingBudgets, + maxRetryDelayMs: this.maxRetryDelayMs, + toolExecution: this.toolExecution, + beforeToolCall: this.beforeToolCall, + afterToolCall: this.afterToolCall, + prepareNextTurn: this.prepareNextTurn + ? async () => await this.prepareNextTurn?.(this.signal) + : undefined, + convertToLlm: this.convertToLlm, + transformContext: this.transformContext, + getApiKey: this.getApiKey, + getSteeringMessages: async () => { + if (skipInitialSteeringPoll) { + skipInitialSteeringPoll = false; + return []; + } + return this.steeringQueue.drain(); + }, + getFollowUpMessages: async () => this.followUpQueue.drain(), + }; + } + + private async runWithLifecycle(executor: (signal: AbortSignal) => Promise): Promise { + if (this.activeRun) { + throw new Error("Agent is already processing."); + } + + const abortController = new AbortController(); + let resolvePromise = () => {}; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + this.activeRun = { promise, resolve: resolvePromise, abortController }; + + this.mutableState.isStreaming = true; + this.mutableState.streamingMessage = undefined; + this.mutableState.errorMessage = undefined; + + try { + await executor(abortController.signal); + } catch (error) { + await this.handleRunFailure(error, abortController.signal.aborted); + } finally { + this.finishRun(); + } + } + + private async handleRunFailure(error: unknown, aborted: boolean): Promise { + const failureMessage = { + role: "assistant", + content: [{ type: "text", text: "" }], + api: this.mutableState.model.api, + provider: this.mutableState.model.provider, + model: this.mutableState.model.id, + usage: EMPTY_USAGE, + stopReason: aborted ? "aborted" : "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + } satisfies AgentMessage; + await this.processEvents({ type: "message_start", message: failureMessage }); + await this.processEvents({ type: "message_end", message: failureMessage }); + await this.processEvents({ type: "turn_end", message: failureMessage, toolResults: [] }); + await this.processEvents({ type: "agent_end", messages: [failureMessage] }); + } + + private finishRun(): void { + this.mutableState.isStreaming = false; + this.mutableState.streamingMessage = undefined; + this.mutableState.pendingToolCalls = new Set(); + this.activeRun?.resolve(); + this.activeRun = undefined; + } + + /** + * Reduce internal state for a loop event, then await listeners. + * + * `agent_end` only means no further loop events will be emitted. The run is + * considered idle later, after all awaited listeners for `agent_end` finish + * and `finishRun()` clears runtime-owned state. + */ + private async processEvents(event: AgentEvent): Promise { + switch (event.type) { + case "agent_start": + case "turn_start": + case "tool_execution_update": + break; + + case "message_start": + this.mutableState.streamingMessage = event.message; + break; + + case "message_update": + this.mutableState.streamingMessage = event.message; + break; + + case "message_end": + this.mutableState.streamingMessage = undefined; + this.mutableState.messages.push(event.message); + break; + + case "tool_execution_start": { + const pendingToolCalls = new Set(this.mutableState.pendingToolCalls); + pendingToolCalls.add(event.toolCallId); + this.mutableState.pendingToolCalls = pendingToolCalls; + break; + } + + case "tool_execution_end": { + const pendingToolCalls = new Set(this.mutableState.pendingToolCalls); + pendingToolCalls.delete(event.toolCallId); + this.mutableState.pendingToolCalls = pendingToolCalls; + break; + } + + case "turn_end": + if (event.message.role === "assistant" && event.message.errorMessage) { + this.mutableState.errorMessage = event.message.errorMessage; + } + break; + + case "agent_end": + this.mutableState.streamingMessage = undefined; + break; + } + + const signal = this.activeRun?.abortController.signal; + if (!signal) { + throw new Error("Agent listener invoked outside active run"); + } + for (const listener of this.listeners) { + await listener(event, signal); + } + } +} diff --git a/packages/agent-core/src/harness/agent-harness.ts b/packages/agent-core/src/harness/agent-harness.ts new file mode 100644 index 00000000000..6e07a4fe04c --- /dev/null +++ b/packages/agent-core/src/harness/agent-harness.ts @@ -0,0 +1,1184 @@ +import { + type AssistantMessage, + type ImageContent, + type Model, + streamSimple, + type UserMessage, +} from "openclaw/plugin-sdk/llm"; +import { runAgentLoop } from "../agent-loop.js"; +import type { + AgentContext, + AgentEvent, + AgentLoopConfig, + AgentMessage, + AgentTool, + QueueMode, + StreamFn, + ThinkingLevel, +} from "../types.js"; +import { + collectEntriesForBranchSummary, + generateBranchSummary, +} from "./compaction/branch-summarization.js"; +import { + compact, + DEFAULT_COMPACTION_SETTINGS, + prepareCompaction, +} from "./compaction/compaction.js"; +import { convertToLlm } from "./messages.js"; +import { formatPromptTemplateInvocation } from "./prompt-templates.js"; +import { formatSkillInvocation } from "./skills.js"; +import type { + AbortResult, + AgentHarnessEvent, + AgentHarnessEventResultMap, + AgentHarnessOptions, + AgentHarnessOwnEvent, + AgentHarnessPhase, + AgentHarnessResources, + AgentHarnessStreamOptions, + AgentHarnessStreamOptionsPatch, + ExecutionEnv, + NavigateTreeResult, + PendingSessionWrite, + PromptTemplate, + Session, + Skill, +} from "./types.js"; +import { + AgentHarnessError, + BranchSummaryError, + CompactionError, + SessionError, + toError, +} from "./types.js"; + +function createUserMessage(text: string, images?: ImageContent[]): UserMessage { + const content: Array<{ type: "text"; text: string } | ImageContent> = [{ type: "text", text }]; + if (images) { + content.push(...images); + } + return { role: "user", content, timestamp: Date.now() }; +} + +function createFailureMessage(model: Model, error: unknown, aborted: boolean): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text: "" }], + api: model.api, + provider: model.provider, + model: model.id, + stopReason: aborted ? "aborted" : "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; +} + +function cloneStreamOptions(streamOptions?: AgentHarnessStreamOptions): AgentHarnessStreamOptions { + return { + ...streamOptions, + headers: streamOptions?.headers ? { ...streamOptions.headers } : undefined, + metadata: streamOptions?.metadata ? { ...streamOptions.metadata } : undefined, + }; +} + +function mergeHeaders( + ...headers: Array | undefined> +): Record | undefined { + const merged: Record = {}; + let hasHeaders = false; + for (const entry of headers) { + if (!entry) { + continue; + } + Object.assign(merged, entry); + hasHeaders = true; + } + return hasHeaders ? merged : undefined; +} + +function applyStreamOptionsPatch( + base: AgentHarnessStreamOptions, + patch?: AgentHarnessStreamOptionsPatch, +): AgentHarnessStreamOptions { + const result = cloneStreamOptions(base); + if (!patch) { + return result; + } + + if (Object.hasOwn(patch, "transport")) { + result.transport = patch.transport; + } + if (Object.hasOwn(patch, "timeoutMs")) { + result.timeoutMs = patch.timeoutMs; + } + if (Object.hasOwn(patch, "maxRetries")) { + result.maxRetries = patch.maxRetries; + } + if (Object.hasOwn(patch, "maxRetryDelayMs")) { + result.maxRetryDelayMs = patch.maxRetryDelayMs; + } + if (Object.hasOwn(patch, "cacheRetention")) { + result.cacheRetention = patch.cacheRetention; + } + + if (Object.hasOwn(patch, "headers")) { + if (patch.headers === undefined) { + result.headers = undefined; + } else { + const headers = { ...result.headers }; + for (const [key, value] of Object.entries(patch.headers)) { + if (value === undefined) { + delete headers[key]; + } else { + headers[key] = value; + } + } + result.headers = Object.keys(headers).length > 0 ? headers : undefined; + } + } + + if (Object.hasOwn(patch, "metadata")) { + if (patch.metadata === undefined) { + result.metadata = undefined; + } else { + const metadata = { ...result.metadata }; + for (const [key, value] of Object.entries(patch.metadata)) { + if (value === undefined) { + delete metadata[key]; + } else { + metadata[key] = value; + } + } + result.metadata = Object.keys(metadata).length > 0 ? metadata : undefined; + } + } + + return result; +} + +const SUBSCRIBER_EVENT_TYPE = "*"; + +type AgentHarnessHandler = (event: unknown, signal?: AbortSignal) => unknown; + +function normalizeHarnessError( + error: unknown, + fallbackCode: AgentHarnessError["code"], +): AgentHarnessError { + if (error instanceof AgentHarnessError) { + return error; + } + const cause = toError(error); + if (cause instanceof SessionError) { + return new AgentHarnessError("session", cause.message, cause); + } + if (cause instanceof CompactionError) { + return new AgentHarnessError("compaction", cause.message, cause); + } + if (cause instanceof BranchSummaryError) { + return new AgentHarnessError("branch_summary", cause.message, cause); + } + return new AgentHarnessError(fallbackCode, cause.message, cause); +} + +function normalizeHookError(error: unknown): AgentHarnessError { + return normalizeHarnessError(error, "hook"); +} + +interface AgentHarnessTurnState< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, + TTool extends AgentTool = AgentTool, +> { + messages: AgentMessage[]; + resources: AgentHarnessResources; + streamOptions: AgentHarnessStreamOptions; + sessionId: string; + systemPrompt: string; + model: Model; + thinkingLevel: ThinkingLevel; + tools: TTool[]; + activeTools: TTool[]; +} + +export class AgentHarness< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, + TTool extends AgentTool = AgentTool, +> { + readonly env: ExecutionEnv; + private session: Session; + private phase: AgentHarnessPhase = "idle"; + private runAbortController?: AbortController; + private runPromise?: Promise; + private pendingSessionWrites: PendingSessionWrite[] = []; + private model: Model; + private thinkingLevel: ThinkingLevel; + private systemPrompt: AgentHarnessOptions["systemPrompt"]; + private streamOptions: AgentHarnessStreamOptions; + private getApiKeyAndHeaders?: AgentHarnessOptions["getApiKeyAndHeaders"]; + private resources: AgentHarnessResources; + private tools = new Map(); + private activeToolNames: string[]; + private steerQueue: UserMessage[] = []; + private steeringQueueMode: QueueMode; + private followUpQueue: UserMessage[] = []; + private followUpQueueMode: QueueMode; + private nextTurnQueue: AgentMessage[] = []; + private handlers = new Map>(); + + constructor(options: AgentHarnessOptions) { + this.env = options.env; + this.session = options.session; + this.resources = options.resources ?? {}; + this.streamOptions = cloneStreamOptions(options.streamOptions); + this.systemPrompt = options.systemPrompt; + this.getApiKeyAndHeaders = options.getApiKeyAndHeaders; + for (const tool of options.tools ?? []) { + this.tools.set(tool.name, tool); + } + this.model = options.model; + this.thinkingLevel = options.thinkingLevel ?? "off"; + this.activeToolNames = + options.activeToolNames ?? (options.tools ?? []).map((tool) => tool.name); + this.steeringQueueMode = options.steeringMode ?? "one-at-a-time"; + this.followUpQueueMode = options.followUpMode ?? "one-at-a-time"; + } + + private getHandlers(type: string): Set | undefined { + return this.handlers.get(type); + } + + private async emitOwn( + event: AgentHarnessOwnEvent, + signal?: AbortSignal, + ): Promise { + for (const listener of this.getHandlers(SUBSCRIBER_EVENT_TYPE) ?? []) { + try { + await listener(event, signal); + } catch (error) { + throw normalizeHookError(error); + } + } + } + + private async emitAny( + event: AgentHarnessEvent, + signal?: AbortSignal, + ): Promise { + for (const listener of this.getHandlers(SUBSCRIBER_EVENT_TYPE) ?? []) { + try { + await listener(event, signal); + } catch (error) { + throw normalizeHookError(error); + } + } + } + + private async emitHook( + event: Extract, + ): Promise { + const handlers = this.getHandlers(event.type); + if (!handlers || handlers.size === 0) { + return undefined; + } + let lastResult: AgentHarnessEventResultMap[TType] | undefined; + for (const handler of handlers) { + try { + const result = (await handler(event)) as AgentHarnessEventResultMap[TType] | undefined; + if (result !== undefined) { + lastResult = result; + } + } catch (error) { + throw normalizeHookError(error); + } + } + return lastResult; + } + + private async emitBeforeProviderRequest( + model: Model, + sessionId: string, + streamOptions: AgentHarnessStreamOptions, + ): Promise { + const handlers = this.getHandlers("before_provider_request"); + let current = cloneStreamOptions(streamOptions); + if (!handlers || handlers.size === 0) { + return current; + } + for (const handler of handlers) { + try { + const result = (await handler({ + type: "before_provider_request", + model, + sessionId, + streamOptions: cloneStreamOptions(current), + })) as AgentHarnessEventResultMap["before_provider_request"]; + if (result?.streamOptions) { + current = applyStreamOptionsPatch(current, result.streamOptions); + } + } catch (error) { + throw normalizeHookError(error); + } + } + return current; + } + + private async emitBeforeProviderPayload(model: Model, payload: unknown): Promise { + const handlers = this.getHandlers("before_provider_payload"); + let current = payload; + if (!handlers || handlers.size === 0) { + return current; + } + for (const handler of handlers) { + try { + const result = (await handler({ + type: "before_provider_payload", + model, + payload: current, + })) as AgentHarnessEventResultMap["before_provider_payload"]; + if (result !== undefined) { + current = result.payload; + } + } catch (error) { + throw normalizeHookError(error); + } + } + return current; + } + + private async emitQueueUpdate(): Promise { + await this.emitOwn({ + type: "queue_update", + steer: [...this.steerQueue], + followUp: [...this.followUpQueue], + nextTurn: [...this.nextTurnQueue], + }); + } + + private startRunPromise(): () => void { + let finish = () => {}; + this.runPromise = new Promise((resolve) => { + finish = resolve; + }); + return () => { + this.runPromise = undefined; + finish(); + }; + } + + private async createTurnState(): Promise> { + const context = await this.session.buildContext(); + const resources = this.getResources(); + const sessionMetadata = await this.session.getMetadata(); + const tools = [...this.tools.values()]; + const activeTools = this.activeToolNames + .map((name) => this.tools.get(name)) + .filter((tool): tool is TTool => tool !== undefined); + let systemPrompt = "You are a helpful assistant."; + if (typeof this.systemPrompt === "string") { + systemPrompt = this.systemPrompt; + } else if (this.systemPrompt) { + systemPrompt = await this.systemPrompt({ + env: this.env, + session: this.session, + model: this.model, + thinkingLevel: this.thinkingLevel, + activeTools, + resources, + }); + } + return { + messages: context.messages, + resources, + streamOptions: cloneStreamOptions(this.streamOptions), + sessionId: sessionMetadata.id, + systemPrompt, + model: this.model, + thinkingLevel: this.thinkingLevel, + tools, + activeTools, + }; + } + + private createContext( + turnState: AgentHarnessTurnState, + systemPrompt?: string, + ): AgentContext { + return { + systemPrompt: systemPrompt ?? turnState.systemPrompt, + messages: turnState.messages.slice(), + tools: turnState.activeTools.slice(), + }; + } + + private createStreamFn( + getTurnState: () => AgentHarnessTurnState, + ): StreamFn { + return async (model, context, streamOptions) => { + const turnState = getTurnState(); + const auth = await this.getApiKeyAndHeaders?.(model); + const snapshotOptions: AgentHarnessStreamOptions = { + ...turnState.streamOptions, + headers: mergeHeaders(turnState.streamOptions.headers, auth?.headers), + }; + const requestOptions = await this.emitBeforeProviderRequest( + model, + turnState.sessionId, + snapshotOptions, + ); + return streamSimple(model, context, { + cacheRetention: requestOptions.cacheRetention, + headers: requestOptions.headers, + maxRetries: requestOptions.maxRetries, + maxRetryDelayMs: requestOptions.maxRetryDelayMs, + metadata: requestOptions.metadata, + onPayload: async (payload) => await this.emitBeforeProviderPayload(model, payload), + onResponse: async (response) => { + const headers = { ...response.headers }; + await this.emitOwn( + { type: "after_provider_response", status: response.status, headers }, + streamOptions?.signal, + ); + }, + reasoning: streamOptions?.reasoning, + signal: streamOptions?.signal, + sessionId: turnState.sessionId, + timeoutMs: requestOptions.timeoutMs, + transport: requestOptions.transport, + apiKey: auth?.apiKey, + }); + }; + } + + private async drainQueuedMessages( + queue: AgentMessage[], + mode: QueueMode, + ): Promise { + const messages = mode === "all" ? queue.splice(0) : queue.splice(0, 1); + if (messages.length === 0) { + return messages; + } + try { + await this.emitQueueUpdate(); + return messages; + } catch (error) { + queue.unshift(...messages); + throw normalizeHookError(error); + } + } + + private createLoopConfig( + getTurnState: () => AgentHarnessTurnState, + setTurnState: (turnState: AgentHarnessTurnState) => void, + ): AgentLoopConfig { + const turnState = getTurnState(); + return { + model: turnState.model, + reasoning: turnState.thinkingLevel === "off" ? undefined : turnState.thinkingLevel, + convertToLlm, + transformContext: async (messages) => { + const result = await this.emitHook({ type: "context", messages: [...messages] }); + return result?.messages ?? messages; + }, + beforeToolCall: async ({ toolCall, args }) => { + const result = await this.emitHook({ + type: "tool_call", + toolCallId: toolCall.id, + toolName: toolCall.name, + input: args as Record, + }); + return result ? { block: result.block, reason: result.reason } : undefined; + }, + afterToolCall: async ({ toolCall, args, result, isError }) => { + const patch = await this.emitHook({ + type: "tool_result", + toolCallId: toolCall.id, + toolName: toolCall.name, + input: args as Record, + content: result.content, + details: result.details, + isError, + }); + return patch + ? { + content: patch.content, + details: patch.details, + isError: patch.isError, + terminate: patch.terminate, + } + : undefined; + }, + prepareNextTurn: async () => { + await this.flushPendingSessionWrites(); + const nextTurnState = await this.createTurnState(); + setTurnState(nextTurnState); + return { + context: this.createContext(nextTurnState), + model: nextTurnState.model, + thinkingLevel: nextTurnState.thinkingLevel, + }; + }, + getSteeringMessages: async () => + this.drainQueuedMessages(this.steerQueue, this.steeringQueueMode), + getFollowUpMessages: async () => + this.drainQueuedMessages(this.followUpQueue, this.followUpQueueMode), + }; + } + + private validateToolNames(toolNames: string[], tools: Map = this.tools): void { + const missing = toolNames.filter((name) => !tools.has(name)); + if (missing.length > 0) { + throw new AgentHarnessError("invalid_argument", `Unknown tool(s): ${missing.join(", ")}`); + } + } + + private async flushPendingSessionWrites(): Promise { + while (this.pendingSessionWrites.length > 0) { + const write = this.pendingSessionWrites[0]; + if (write.type === "message") { + await this.session.appendMessage(write.message); + } else if (write.type === "model_change") { + await this.session.appendModelChange(write.provider, write.modelId); + } else if (write.type === "thinking_level_change") { + await this.session.appendThinkingLevelChange(write.thinkingLevel); + } else if (write.type === "custom") { + await this.session.appendCustomEntry(write.customType, write.data); + } else if (write.type === "custom_message") { + await this.session.appendCustomMessageEntry( + write.customType, + write.content, + write.display, + write.details, + ); + } else if (write.type === "label") { + await this.session.appendLabel(write.targetId, write.label); + } else if (write.type === "session_info") { + await this.session.appendSessionName(write.name ?? ""); + } else if (write.type === "leaf") { + await this.session.getStorage().setLeafId(write.targetId); + } + this.pendingSessionWrites.shift(); + } + } + + private async handleAgentEvent(event: AgentEvent, signal?: AbortSignal): Promise { + if (event.type === "message_end") { + await this.session.appendMessage(event.message); + await this.emitAny(event, signal); + return; + } + if (event.type === "turn_end") { + let eventError: unknown; + try { + await this.emitAny(event, signal); + } catch (error) { + eventError = error; + } + const hadPendingMutations = this.pendingSessionWrites.length > 0; + await this.flushPendingSessionWrites(); + if (eventError) { + throw eventError; + } + await this.emitOwn({ type: "save_point", hadPendingMutations }); + return; + } + if (event.type === "agent_end") { + await this.flushPendingSessionWrites(); + this.phase = "idle"; + await this.emitAny(event, signal); + await this.emitOwn({ type: "settled", nextTurnCount: this.nextTurnQueue.length }, signal); + return; + } + await this.emitAny(event, signal); + } + + private async emitRunFailure( + model: Model, + error: unknown, + aborted: boolean, + signal: AbortSignal, + ): Promise { + const failureMessage = createFailureMessage(model, error, aborted); + await this.handleAgentEvent({ type: "message_start", message: failureMessage }, signal); + await this.handleAgentEvent({ type: "message_end", message: failureMessage }, signal); + await this.handleAgentEvent( + { type: "turn_end", message: failureMessage, toolResults: [] }, + signal, + ); + await this.handleAgentEvent({ type: "agent_end", messages: [failureMessage] }, signal); + return [failureMessage]; + } + + private async executeTurn( + turnState: AgentHarnessTurnState, + text: string, + options?: { images?: ImageContent[] }, + ): Promise { + let activeTurnState = turnState; + let messages: AgentMessage[] = [createUserMessage(text, options?.images)]; + if (this.nextTurnQueue.length > 0) { + const queuedMessages = this.nextTurnQueue.splice(0); + try { + await this.emitQueueUpdate(); + } catch (error) { + this.nextTurnQueue.unshift(...queuedMessages); + throw normalizeHookError(error); + } + messages = [...queuedMessages, messages[0]]; + } + const beforeResult = await this.emitHook({ + type: "before_agent_start", + prompt: text, + images: options?.images, + systemPrompt: turnState.systemPrompt, + resources: turnState.resources, + }); + if (beforeResult?.messages) { + messages = [...messages, ...beforeResult.messages]; + } + + const abortController = new AbortController(); + const getTurnState = () => activeTurnState; + const setTurnState = (nextTurnState: AgentHarnessTurnState) => { + activeTurnState = nextTurnState; + }; + this.runAbortController = abortController; + const runResultPromise = (async () => { + try { + return await runAgentLoop( + messages, + this.createContext(turnState, beforeResult?.systemPrompt), + this.createLoopConfig(getTurnState, setTurnState), + (event) => this.handleAgentEvent(event, abortController.signal), + abortController.signal, + this.createStreamFn(getTurnState), + ); + } catch (error) { + try { + return await this.emitRunFailure( + activeTurnState.model, + error, + abortController.signal.aborted, + abortController.signal, + ); + } catch (failureError) { + const cause = new AggregateError( + [toError(error), toError(failureError)], + "Agent run failed and failure reporting failed", + ); + throw new AgentHarnessError("unknown", cause.message, cause); + } + } + })(); + try { + const newMessages = await runResultPromise; + for (let i = newMessages.length - 1; i >= 0; i--) { + const message = newMessages[i]; + if (message.role === "assistant") { + return message; + } + } + throw new AgentHarnessError( + "invalid_state", + "AgentHarness prompt completed without an assistant message", + ); + } finally { + try { + await this.flushPendingSessionWrites(); + } finally { + this.runAbortController = undefined; + } + } + } + + async prompt(text: string, options?: { images?: ImageContent[] }): Promise { + if (this.phase !== "idle") { + throw new AgentHarnessError("busy", "AgentHarness is busy"); + } + this.phase = "turn"; + const finishRunPromise = this.startRunPromise(); + try { + const turnState = await this.createTurnState(); + return await this.executeTurn(turnState, text, options); + } catch (error) { + this.phase = "idle"; + throw normalizeHarnessError(error, "unknown"); + } finally { + finishRunPromise(); + } + } + + async skill(name: string, additionalInstructions?: string): Promise { + if (this.phase !== "idle") { + throw new AgentHarnessError("busy", "AgentHarness is busy"); + } + this.phase = "turn"; + const finishRunPromise = this.startRunPromise(); + try { + const turnState = await this.createTurnState(); + const skill = (turnState.resources.skills ?? []).find((candidate) => candidate.name === name); + if (!skill) { + throw new AgentHarnessError("invalid_argument", `Unknown skill: ${name}`); + } + return await this.executeTurn( + turnState, + formatSkillInvocation(skill, additionalInstructions), + ); + } catch (error) { + this.phase = "idle"; + throw normalizeHarnessError(error, "unknown"); + } finally { + finishRunPromise(); + } + } + + async promptFromTemplate(name: string, args: string[] = []): Promise { + if (this.phase !== "idle") { + throw new AgentHarnessError("busy", "AgentHarness is busy"); + } + this.phase = "turn"; + const finishRunPromise = this.startRunPromise(); + try { + const turnState = await this.createTurnState(); + const template = (turnState.resources.promptTemplates ?? []).find( + (candidate) => candidate.name === name, + ); + if (!template) { + throw new AgentHarnessError("invalid_argument", `Unknown prompt template: ${name}`); + } + return await this.executeTurn(turnState, formatPromptTemplateInvocation(template, args)); + } catch (error) { + this.phase = "idle"; + throw normalizeHarnessError(error, "unknown"); + } finally { + finishRunPromise(); + } + } + + async steer(text: string, options?: { images?: ImageContent[] }): Promise { + if (this.phase === "idle") { + throw new AgentHarnessError("invalid_state", "Cannot steer while idle"); + } + this.steerQueue.push(createUserMessage(text, options?.images)); + await this.emitQueueUpdate(); + } + + async followUp(text: string, options?: { images?: ImageContent[] }): Promise { + if (this.phase === "idle") { + throw new AgentHarnessError("invalid_state", "Cannot follow up while idle"); + } + this.followUpQueue.push(createUserMessage(text, options?.images)); + await this.emitQueueUpdate(); + } + + async nextTurn(text: string, options?: { images?: ImageContent[] }): Promise { + this.nextTurnQueue.push(createUserMessage(text, options?.images)); + await this.emitQueueUpdate(); + } + + async appendMessage(message: AgentMessage): Promise { + try { + if (this.phase === "idle") { + await this.session.appendMessage(message); + } else { + this.pendingSessionWrites.push({ type: "message", message }); + } + } catch (error) { + throw normalizeHarnessError(error, "session"); + } + } + + async compact(customInstructions?: string): Promise<{ + summary: string; + firstKeptEntryId: string; + tokensBefore: number; + details?: unknown; + }> { + if (this.phase !== "idle") { + throw new AgentHarnessError("busy", "compact() requires idle harness"); + } + this.phase = "compaction"; + try { + const model = this.model; + if (!model) { + throw new AgentHarnessError("invalid_state", "No model set for compaction"); + } + const auth = await this.getApiKeyAndHeaders?.(model); + if (!auth) { + throw new AgentHarnessError("auth", "No auth available for compaction"); + } + const branchEntries = await this.session.getBranch(); + const preparationResult = prepareCompaction(branchEntries, DEFAULT_COMPACTION_SETTINGS); + if (!preparationResult.ok) { + throw preparationResult.error; + } + const preparation = preparationResult.value; + if (!preparation) { + throw new AgentHarnessError("compaction", "Nothing to compact"); + } + const hookResult = await this.emitHook({ + type: "session_before_compact", + preparation, + branchEntries, + customInstructions, + signal: new AbortController().signal, + }); + if (hookResult?.cancel) { + throw new AgentHarnessError("compaction", "Compaction cancelled"); + } + const provided = hookResult?.compaction; + const compactResult = provided + ? { ok: true as const, value: provided } + : await compact( + preparation, + model, + auth.apiKey, + auth.headers, + customInstructions, + undefined, + this.thinkingLevel, + ); + if (!compactResult.ok) { + throw compactResult.error; + } + const result = compactResult.value; + const entryId = await this.session.appendCompaction( + result.summary, + result.firstKeptEntryId, + result.tokensBefore, + result.details, + provided !== undefined, + ); + const entry = await this.session.getEntry(entryId); + if (entry?.type === "compaction") { + await this.emitOwn({ + type: "session_compact", + compactionEntry: entry, + fromHook: provided !== undefined, + }); + } + return result; + } catch (error) { + throw normalizeHarnessError(error, "compaction"); + } finally { + this.phase = "idle"; + } + } + + async navigateTree( + targetId: string, + options?: { + summarize?: boolean; + customInstructions?: string; + replaceInstructions?: boolean; + label?: string; + }, + ): Promise { + if (this.phase !== "idle") { + throw new AgentHarnessError("busy", "navigateTree() requires idle harness"); + } + this.phase = "branch_summary"; + try { + const oldLeafId = await this.session.getLeafId(); + if (oldLeafId === targetId) { + return { cancelled: false }; + } + const targetEntry = await this.session.getEntry(targetId); + if (!targetEntry) { + throw new AgentHarnessError("invalid_argument", `Entry ${targetId} not found`); + } + const { entries, commonAncestorId } = await collectEntriesForBranchSummary( + this.session, + oldLeafId, + targetId, + ); + const preparation = { + targetId, + oldLeafId, + commonAncestorId, + entriesToSummarize: entries, + userWantsSummary: options?.summarize ?? false, + customInstructions: options?.customInstructions, + replaceInstructions: options?.replaceInstructions, + label: options?.label, + }; + const signal = new AbortController().signal; + const hookResult = await this.emitHook({ type: "session_before_tree", preparation, signal }); + if (hookResult?.cancel) { + return { cancelled: true }; + } + let summaryEntry: NavigateTreeResult["summaryEntry"]; + let summaryText: string | undefined = hookResult?.summary?.summary; + let summaryDetails: unknown = hookResult?.summary?.details; + if (!summaryText && options?.summarize && entries.length > 0) { + const model = this.model; + if (!model) { + throw new AgentHarnessError("invalid_state", "No model set for branch summary"); + } + const auth = await this.getApiKeyAndHeaders?.(model); + if (!auth) { + throw new AgentHarnessError("auth", "No auth available for branch summary"); + } + const branchSummary = await generateBranchSummary(entries, { + model, + apiKey: auth.apiKey, + headers: auth.headers, + signal: new AbortController().signal, + customInstructions: hookResult?.customInstructions ?? options?.customInstructions, + replaceInstructions: hookResult?.replaceInstructions ?? options?.replaceInstructions, + }); + if (!branchSummary.ok) { + if (branchSummary.error.code === "aborted") { + return { cancelled: true }; + } + throw new AgentHarnessError( + "branch_summary", + branchSummary.error.message, + branchSummary.error, + ); + } + summaryText = branchSummary.value.summary; + summaryDetails = { + readFiles: branchSummary.value.readFiles, + modifiedFiles: branchSummary.value.modifiedFiles, + }; + } + let editorText: string | undefined; + let newLeafId: string | null; + if (targetEntry.type === "message" && targetEntry.message.role === "user") { + newLeafId = targetEntry.parentId; + const content = targetEntry.message.content; + editorText = + typeof content === "string" + ? content + : content + .filter( + (c): c is { readonly type: "text"; readonly text: string } => c.type === "text", + ) + .map((c) => c.text) + .join(""); + } else if (targetEntry.type === "custom_message") { + newLeafId = targetEntry.parentId; + editorText = + typeof targetEntry.content === "string" + ? targetEntry.content + : targetEntry.content + .filter( + (c): c is { readonly type: "text"; readonly text: string } => c.type === "text", + ) + .map((c) => c.text) + .join(""); + } else { + newLeafId = targetId; + } + const summaryId = await this.session.moveTo( + newLeafId, + summaryText + ? { + summary: summaryText, + details: summaryDetails, + fromHook: hookResult?.summary !== undefined, + } + : undefined, + ); + if (summaryId) { + const entry = await this.session.getEntry(summaryId); + if (entry?.type === "branch_summary") { + summaryEntry = entry; + } + } + await this.emitOwn({ + type: "session_tree", + newLeafId: await this.session.getLeafId(), + oldLeafId, + summaryEntry, + fromHook: hookResult?.summary !== undefined, + }); + return { cancelled: false, editorText, summaryEntry }; + } catch (error) { + throw normalizeHarnessError(error, "branch_summary"); + } finally { + this.phase = "idle"; + } + } + + getModel(): Model { + return this.model; + } + + getThinkingLevel(): ThinkingLevel { + return this.thinkingLevel; + } + + async setModel(model: Model): Promise { + try { + const previousModel = this.model; + if (this.phase === "idle") { + await this.session.appendModelChange(model.provider, model.id); + } else { + this.pendingSessionWrites.push({ + type: "model_change", + provider: model.provider, + modelId: model.id, + }); + } + this.model = model; + await this.emitOwn({ type: "model_select", model, previousModel, source: "set" }); + } catch (error) { + throw normalizeHarnessError(error, "session"); + } + } + + async setThinkingLevel(level: ThinkingLevel): Promise { + try { + const previousLevel = this.thinkingLevel; + if (this.phase === "idle") { + await this.session.appendThinkingLevelChange(level); + } else { + this.pendingSessionWrites.push({ type: "thinking_level_change", thinkingLevel: level }); + } + this.thinkingLevel = level; + await this.emitOwn({ type: "thinking_level_select", level, previousLevel }); + } catch (error) { + throw normalizeHarnessError(error, "session"); + } + } + + async setActiveTools(toolNames: string[]): Promise { + try { + this.validateToolNames(toolNames); + this.activeToolNames = [...toolNames]; + } catch (error) { + throw normalizeHarnessError(error, "invalid_argument"); + } + } + + getSteeringMode(): QueueMode { + return this.steeringQueueMode; + } + + async setSteeringMode(mode: QueueMode): Promise { + this.steeringQueueMode = mode; + } + + getFollowUpMode(): QueueMode { + return this.followUpQueueMode; + } + + async setFollowUpMode(mode: QueueMode): Promise { + this.followUpQueueMode = mode; + } + + getResources(): AgentHarnessResources { + return { + skills: this.resources.skills?.slice(), + promptTemplates: this.resources.promptTemplates?.slice(), + }; + } + + async setResources(resources: AgentHarnessResources): Promise { + const previousResources = this.getResources(); + this.resources = { + skills: resources.skills?.slice(), + promptTemplates: resources.promptTemplates?.slice(), + }; + await this.emitOwn({ + type: "resources_update", + resources: this.getResources(), + previousResources, + }); + } + + getStreamOptions(): AgentHarnessStreamOptions { + return cloneStreamOptions(this.streamOptions); + } + + async setStreamOptions(streamOptions: AgentHarnessStreamOptions): Promise { + this.streamOptions = cloneStreamOptions(streamOptions); + } + + async setTools(tools: TTool[], activeToolNames?: string[]): Promise { + try { + const nextTools = new Map(tools.map((tool) => [tool.name, tool])); + const nextActiveToolNames = activeToolNames ? [...activeToolNames] : this.activeToolNames; + this.validateToolNames(nextActiveToolNames, nextTools); + this.tools = nextTools; + this.activeToolNames = [...nextActiveToolNames]; + } catch (error) { + throw normalizeHarnessError(error, "invalid_argument"); + } + } + + async abort(): Promise { + const clearedSteer = [...this.steerQueue]; + const clearedFollowUp = [...this.followUpQueue]; + this.steerQueue = []; + this.followUpQueue = []; + this.runAbortController?.abort(); + const errors: Error[] = []; + try { + await this.emitQueueUpdate(); + } catch (error) { + errors.push(toError(error)); + } + try { + await this.waitForIdle(); + } catch (error) { + errors.push(toError(error)); + } + try { + await this.emitOwn({ type: "abort", clearedSteer, clearedFollowUp }); + } catch (error) { + errors.push(toError(error)); + } + if (errors.length > 0) { + const cause = + errors.length === 1 ? errors[0] : new AggregateError(errors, "Abort completed with errors"); + throw normalizeHarnessError(cause, "hook"); + } + return { clearedSteer, clearedFollowUp }; + } + + async waitForIdle(): Promise { + await this.runPromise; + } + + subscribe( + listener: ( + event: AgentHarnessEvent, + signal?: AbortSignal, + ) => Promise | void, + ): () => void { + let handlers = this.handlers.get(SUBSCRIBER_EVENT_TYPE); + if (!handlers) { + handlers = new Set(); + this.handlers.set(SUBSCRIBER_EVENT_TYPE, handlers); + } + handlers.add(listener as AgentHarnessHandler); + return () => handlers.delete(listener as AgentHarnessHandler); + } + + on( + type: TType, + handler: ( + event: Extract, + ) => Promise | AgentHarnessEventResultMap[TType], + ): () => void { + let handlers = this.handlers.get(type); + if (!handlers) { + handlers = new Set(); + this.handlers.set(type, handlers); + } + handlers.add(handler as AgentHarnessHandler); + return () => handlers.delete(handler as AgentHarnessHandler); + } +} diff --git a/packages/agent-core/src/harness/compaction/branch-summarization.ts b/packages/agent-core/src/harness/compaction/branch-summarization.ts new file mode 100644 index 00000000000..0a78053dd24 --- /dev/null +++ b/packages/agent-core/src/harness/compaction/branch-summarization.ts @@ -0,0 +1,290 @@ +import type { Model } from "openclaw/plugin-sdk/llm"; +import { completeSimple } from "openclaw/plugin-sdk/llm"; +import type { AgentMessage } from "../../types.js"; +import { + convertToLlm, + createBranchSummaryMessage, + createCompactionSummaryMessage, + createCustomMessage, +} from "../messages.js"; +import type { BranchSummaryResult, Session, SessionTreeEntry } from "../types.js"; +import { BranchSummaryError, err, ok, type Result, SessionError } from "../types.js"; +import { estimateTokens, SUMMARIZATION_SYSTEM_PROMPT } from "./compaction.js"; +import { + computeFileLists, + createFileOps, + extractFileOpsFromMessage, + type FileOperations, + formatFileOperations, + serializeConversation, +} from "./utils.js"; + +/** File-operation details stored on generated branch summary entries. */ +export interface BranchSummaryDetails { + /** Files read while exploring the summarized branch. */ + readFiles: string[]; + /** Files modified while exploring the summarized branch. */ + modifiedFiles: string[]; +} + +export type { FileOperations } from "./utils.js"; + +/** Prepared branch content for summarization. */ +export interface BranchPreparation { + /** Messages selected for the branch summary. */ + messages: AgentMessage[]; + /** File operations extracted from the branch. */ + fileOps: FileOperations; + /** Estimated token count for selected messages. */ + totalTokens: number; +} + +/** Entries selected for branch summarization. */ +export interface CollectEntriesResult { + /** Entries to summarize in chronological order. */ + entries: SessionTreeEntry[]; + /** Deepest common ancestor between the previous leaf and target entry. */ + commonAncestorId: string | null; +} + +/** Options for generating a branch summary. */ +export interface GenerateBranchSummaryOptions { + /** Model used for summarization. */ + model: Model; + /** API key forwarded to the provider. */ + apiKey: string; + /** Optional request headers forwarded to the provider. */ + headers?: Record; + /** Abort signal for the summarization request. */ + signal: AbortSignal; + /** Optional instructions appended to or replacing the default prompt. */ + customInstructions?: string; + /** Replace the default prompt with custom instructions instead of appending them. */ + replaceInstructions?: boolean; + /** Tokens reserved for prompt and model output. Defaults to 16384. */ + reserveTokens?: number; +} + +/** Collect entries that should be summarized before navigating to a different session tree entry. */ +export async function collectEntriesForBranchSummary( + session: Session, + oldLeafId: string | null, + targetId: string, +): Promise { + if (!oldLeafId) { + return { entries: [], commonAncestorId: null }; + } + const oldPath = new Set((await session.getBranch(oldLeafId)).map((e) => e.id)); + const targetPath = await session.getBranch(targetId); + let commonAncestorId: string | null = null; + for (let i = targetPath.length - 1; i >= 0; i--) { + if (oldPath.has(targetPath[i].id)) { + commonAncestorId = targetPath[i].id; + break; + } + } + const entries: SessionTreeEntry[] = []; + let current: string | null = oldLeafId; + + while (current && current !== commonAncestorId) { + const entry = await session.getEntry(current); + if (!entry) { + throw new SessionError("invalid_session", `Entry ${current} not found`); + } + entries.push(entry); + current = entry.parentId; + } + entries.reverse(); + + return { entries, commonAncestorId }; +} +function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined { + switch (entry.type) { + case "message": + if (entry.message.role === "toolResult") { + return undefined; + } + return entry.message; + + case "custom_message": + return createCustomMessage( + entry.customType, + entry.content, + entry.display, + entry.details, + entry.timestamp, + ); + + case "branch_summary": + return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp); + + case "compaction": + return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp); + case "thinking_level_change": + case "model_change": + case "custom": + case "label": + case "session_info": + case "leaf": + return undefined; + } + return undefined; +} + +/** Prepare branch entries for summarization within an optional token budget. */ +export function prepareBranchEntries( + entries: SessionTreeEntry[], + tokenBudget: number = 0, +): BranchPreparation { + const messages: AgentMessage[] = []; + const fileOps = createFileOps(); + let totalTokens = 0; + for (const entry of entries) { + if (entry.type === "branch_summary" && !entry.fromHook && entry.details) { + const details = entry.details as BranchSummaryDetails; + if (Array.isArray(details.readFiles)) { + for (const f of details.readFiles) { + fileOps.read.add(f); + } + } + if (Array.isArray(details.modifiedFiles)) { + for (const f of details.modifiedFiles) { + fileOps.edited.add(f); + } + } + } + } + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + const message = getMessageFromEntry(entry); + if (!message) { + continue; + } + extractFileOpsFromMessage(message, fileOps); + + const tokens = estimateTokens(message); + if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) { + if (entry.type === "compaction" || entry.type === "branch_summary") { + if (totalTokens < tokenBudget * 0.9) { + messages.unshift(message); + totalTokens += tokens; + } + } + break; + } + + messages.unshift(message); + totalTokens += tokens; + } + + return { messages, fileOps, totalTokens }; +} + +const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here. +Summary of that exploration: + +`; + +const BRANCH_SUMMARY_PROMPT = `Create a structured summary of this conversation branch for context when returning later. + +Use this EXACT format: + +## Goal +[What was the user trying to accomplish in this branch?] + +## Constraints & Preferences +- [Any constraints, preferences, or requirements mentioned] +- [Or "(none)" if none were mentioned] + +## Progress +### Done +- [x] [Completed tasks/changes] + +### In Progress +- [ ] [Work that was started but not finished] + +### Blocked +- [Issues preventing progress, if any] + +## Key Decisions +- **[Decision]**: [Brief rationale] + +## Next Steps +1. [What should happen next to continue this work] + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; + +/** Generate a summary for abandoned branch entries. */ +export async function generateBranchSummary( + entries: SessionTreeEntry[], + options: GenerateBranchSummaryOptions, +): Promise> { + const { + model, + apiKey, + headers, + signal, + customInstructions, + replaceInstructions, + reserveTokens = 16384, + } = options; + const contextWindow = model.contextWindow || 128000; + const tokenBudget = contextWindow - reserveTokens; + + const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget); + + if (messages.length === 0) { + return ok({ summary: "No content to summarize", readFiles: [], modifiedFiles: [] }); + } + const llmMessages = convertToLlm(messages); + const conversationText = serializeConversation(llmMessages); + let instructions: string; + if (replaceInstructions && customInstructions) { + instructions = customInstructions; + } else if (customInstructions) { + instructions = `${BRANCH_SUMMARY_PROMPT}\n\nAdditional focus: ${customInstructions}`; + } else { + instructions = BRANCH_SUMMARY_PROMPT; + } + const promptText = `\n${conversationText}\n\n\n${instructions}`; + + const summarizationMessages = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: promptText }], + timestamp: Date.now(), + }, + ]; + const response = await completeSimple( + model, + { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + { apiKey, headers, signal, maxTokens: 2048 }, + ); + if (response.stopReason === "aborted") { + return err( + new BranchSummaryError("aborted", response.errorMessage || "Branch summary aborted"), + ); + } + if (response.stopReason === "error") { + return err( + new BranchSummaryError( + "summarization_failed", + `Branch summary failed: ${response.errorMessage || "Unknown error"}`, + ), + ); + } + + let summary = response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); + summary = BRANCH_SUMMARY_PREAMBLE + summary; + const { readFiles, modifiedFiles } = computeFileLists(fileOps); + summary += formatFileOperations(readFiles, modifiedFiles); + + return ok({ + summary: summary || "No summary generated", + readFiles, + modifiedFiles, + }); +} diff --git a/packages/agent-core/src/harness/compaction/compaction.ts b/packages/agent-core/src/harness/compaction/compaction.ts new file mode 100644 index 00000000000..1b755a7946f --- /dev/null +++ b/packages/agent-core/src/harness/compaction/compaction.ts @@ -0,0 +1,817 @@ +import type { Model, Usage } from "openclaw/plugin-sdk/llm"; +import { completeSimple } from "openclaw/plugin-sdk/llm"; +import type { AgentMessage, ThinkingLevel } from "../../types.js"; +import { + convertToLlm, + createBranchSummaryMessage, + createCompactionSummaryMessage, + createCustomMessage, +} from "../messages.js"; +import { buildSessionContext } from "../session/session.js"; +import { + type CompactionEntry, + CompactionError, + err, + ok, + type Result, + type SessionTreeEntry, +} from "../types.js"; +import { + computeFileLists, + createFileOps, + extractFileOpsFromMessage, + type FileOperations, + formatFileOperations, + serializeConversation, +} from "./utils.js"; + +/** File-operation details stored on generated compaction entries. */ +export interface CompactionDetails { + /** Files read in the compacted history. */ + readFiles: string[]; + /** Files modified in the compacted history. */ + modifiedFiles: string[]; +} +function safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value) ?? "undefined"; + } catch { + return "[unserializable]"; + } +} + +function extractFileOperations( + messages: AgentMessage[], + entries: SessionTreeEntry[], + prevCompactionIndex: number, +): FileOperations { + const fileOps = createFileOps(); + if (prevCompactionIndex >= 0) { + const prevCompaction = entries[prevCompactionIndex] as CompactionEntry; + if (!prevCompaction.fromHook && prevCompaction.details) { + const details = prevCompaction.details as CompactionDetails; + if (Array.isArray(details.readFiles)) { + for (const f of details.readFiles) { + fileOps.read.add(f); + } + } + if (Array.isArray(details.modifiedFiles)) { + for (const f of details.modifiedFiles) { + fileOps.edited.add(f); + } + } + } + } + for (const msg of messages) { + extractFileOpsFromMessage(msg, fileOps); + } + + return fileOps; +} +function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined { + if (entry.type === "message") { + return entry.message; + } + if (entry.type === "custom_message") { + return createCustomMessage( + entry.customType, + entry.content, + entry.display, + entry.details, + entry.timestamp, + ); + } + if (entry.type === "branch_summary") { + return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp); + } + if (entry.type === "compaction") { + return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp); + } + return undefined; +} + +function getMessageFromEntryForCompaction(entry: SessionTreeEntry): AgentMessage | undefined { + if (entry.type === "compaction") { + return undefined; + } + return getMessageFromEntry(entry); +} + +/** Generated compaction data ready to be persisted as a compaction entry. */ +export interface CompactionResult { + /** Summary text that replaces compacted history in future context. */ + summary: string; + /** Entry id where retained history starts. */ + firstKeptEntryId: string; + /** Estimated context tokens before compaction. */ + tokensBefore: number; + /** Optional implementation-specific details stored with the compaction entry. */ + details?: T; +} + +/** Compaction thresholds and retention settings. */ +export interface CompactionSettings { + /** Enable automatic compaction decisions. */ + enabled: boolean; + /** Tokens reserved for summary prompt and output. */ + reserveTokens: number; + /** Approximate recent-context tokens to keep after compaction. */ + keepRecentTokens: number; +} + +/** Default compaction settings used by the harness. */ +export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = { + enabled: true, + reserveTokens: 16384, + keepRecentTokens: 20000, +}; + +/** Calculate total context tokens from provider usage. */ +export function calculateContextTokens(usage: Usage): number { + return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite; +} +function getAssistantUsage(msg: AgentMessage): Usage | undefined { + if (msg.role === "assistant" && "usage" in msg) { + const assistantMsg = msg; + if ( + assistantMsg.stopReason !== "aborted" && + assistantMsg.stopReason !== "error" && + assistantMsg.usage + ) { + return assistantMsg.usage; + } + } + return undefined; +} + +/** Return usage from the last successful assistant message in session entries. */ +export function getLastAssistantUsage(entries: SessionTreeEntry[]): Usage | undefined { + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry.type === "message") { + const usage = getAssistantUsage(entry.message); + if (usage) { + return usage; + } + } + } + return undefined; +} + +/** Estimated context-token usage for a message list. */ +export interface ContextUsageEstimate { + /** Estimated total context tokens. */ + tokens: number; + /** Tokens reported by the most recent assistant usage block. */ + usageTokens: number; + /** Estimated tokens after the most recent assistant usage block. */ + trailingTokens: number; + /** Index of the message that provided usage, or null when none exists. */ + lastUsageIndex: number | null; +} + +function getLastAssistantUsageInfo( + messages: AgentMessage[], +): { usage: Usage; index: number } | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const usage = getAssistantUsage(messages[i]); + if (usage) { + return { usage, index: i }; + } + } + return undefined; +} + +/** Estimate context tokens for messages using provider usage when available. */ +export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEstimate { + const usageInfo = getLastAssistantUsageInfo(messages); + + if (!usageInfo) { + let estimated = 0; + for (const message of messages) { + estimated += estimateTokens(message); + } + return { + tokens: estimated, + usageTokens: 0, + trailingTokens: estimated, + lastUsageIndex: null, + }; + } + + const usageTokens = calculateContextTokens(usageInfo.usage); + let trailingTokens = 0; + for (let i = usageInfo.index + 1; i < messages.length; i++) { + trailingTokens += estimateTokens(messages[i]); + } + + return { + tokens: usageTokens + trailingTokens, + usageTokens, + trailingTokens, + lastUsageIndex: usageInfo.index, + }; +} + +/** Return whether context usage exceeds the configured compaction threshold. */ +export function shouldCompact( + contextTokens: number, + contextWindow: number, + settings: CompactionSettings, +): boolean { + if (!settings.enabled) { + return false; + } + return contextTokens > contextWindow - settings.reserveTokens; +} + +/** Estimate token count for one message using a conservative character heuristic. */ +export function estimateTokens(message: AgentMessage): number { + let chars = 0; + + switch (message.role) { + case "user": { + const content = (message as { content: string | Array<{ type: string; text?: string }> }) + .content; + if (typeof content === "string") { + chars = content.length; + } else if (Array.isArray(content)) { + for (const block of content) { + if (block.type === "text" && block.text) { + chars += block.text.length; + } + } + } + return Math.ceil(chars / 4); + } + case "assistant": { + const assistant = message; + for (const block of assistant.content) { + if (block.type === "text") { + chars += block.text.length; + } else if (block.type === "thinking") { + chars += block.thinking.length; + } else if (block.type === "toolCall") { + chars += block.name.length + safeJsonStringify(block.arguments).length; + } + } + return Math.ceil(chars / 4); + } + case "custom": + case "toolResult": { + if (typeof message.content === "string") { + chars = message.content.length; + } else { + for (const block of message.content) { + if (block.type === "text" && block.text) { + chars += block.text.length; + } + if (block.type === "image") { + chars += 4800; + } + } + } + return Math.ceil(chars / 4); + } + case "bashExecution": { + chars = message.command.length + message.output.length; + return Math.ceil(chars / 4); + } + case "branchSummary": + case "compactionSummary": { + chars = message.summary.length; + return Math.ceil(chars / 4); + } + } + + return 0; +} +function findValidCutPoints( + entries: SessionTreeEntry[], + startIndex: number, + endIndex: number, +): number[] { + const cutPoints: number[] = []; + for (let i = startIndex; i < endIndex; i++) { + const entry = entries[i]; + switch (entry.type) { + case "message": { + const role = entry.message.role; + switch (role) { + case "bashExecution": + case "custom": + case "branchSummary": + case "compactionSummary": + case "user": + case "assistant": + cutPoints.push(i); + break; + case "toolResult": + break; + } + break; + } + case "thinking_level_change": + case "model_change": + case "compaction": + case "branch_summary": + case "custom": + case "custom_message": + case "label": + case "session_info": + case "leaf": + break; + } + if (entry.type === "branch_summary" || entry.type === "custom_message") { + cutPoints.push(i); + } + } + return cutPoints; +} + +/** Find the user-visible message that starts the turn containing an entry. */ +export function findTurnStartIndex( + entries: SessionTreeEntry[], + entryIndex: number, + startIndex: number, +): number { + for (let i = entryIndex; i >= startIndex; i--) { + const entry = entries[i]; + if (entry.type === "branch_summary" || entry.type === "custom_message") { + return i; + } + if (entry.type === "message") { + const role = entry.message.role; + if (role === "user" || role === "bashExecution") { + return i; + } + } + } + return -1; +} + +/** Cut point selected for compaction. */ +export interface CutPointResult { + /** Index of the first entry retained after compaction. */ + firstKeptEntryIndex: number; + /** Index of the turn-start entry when the cut splits a turn, otherwise -1. */ + turnStartIndex: number; + /** Whether the selected cut point splits an in-progress turn. */ + isSplitTurn: boolean; +} + +/** Find the compaction cut point that keeps approximately the requested recent-token budget. */ +export function findCutPoint( + entries: SessionTreeEntry[], + startIndex: number, + endIndex: number, + keepRecentTokens: number, +): CutPointResult { + const cutPoints = findValidCutPoints(entries, startIndex, endIndex); + + if (cutPoints.length === 0) { + return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false }; + } + let accumulatedTokens = 0; + let cutIndex = cutPoints[0]; + + for (let i = endIndex - 1; i >= startIndex; i--) { + const entry = entries[i]; + if (entry.type !== "message") { + continue; + } + const messageTokens = estimateTokens(entry.message); + accumulatedTokens += messageTokens; + if (accumulatedTokens >= keepRecentTokens) { + for (let c = 0; c < cutPoints.length; c++) { + if (cutPoints[c] >= i) { + cutIndex = cutPoints[c]; + break; + } + } + break; + } + } + while (cutIndex > startIndex) { + const prevEntry = entries[cutIndex - 1]; + if (prevEntry.type === "compaction") { + break; + } + if (prevEntry.type === "message") { + break; + } + cutIndex--; + } + const cutEntry = entries[cutIndex]; + const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user"; + const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex); + + return { + firstKeptEntryIndex: cutIndex, + turnStartIndex, + isSplitTurn: !isUserMessage && turnStartIndex !== -1, + }; +} + +export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified. + +Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`; + +const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work. + +Use this EXACT format: + +## Goal +[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.] + +## Constraints & Preferences +- [Any constraints, preferences, or requirements mentioned by user] +- [Or "(none)" if none were mentioned] + +## Progress +### Done +- [x] [Completed tasks/changes] + +### In Progress +- [ ] [Current work] + +### Blocked +- [Issues preventing progress, if any] + +## Key Decisions +- **[Decision]**: [Brief rationale] + +## Next Steps +1. [Ordered list of what should happen next] + +## Critical Context +- [Any data, examples, or references needed to continue] +- [Or "(none)" if not applicable] + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; + +const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in tags. + +Update the existing structured summary with new information. RULES: +- PRESERVE all existing information from the previous summary +- ADD new progress, decisions, and context from the new messages +- UPDATE the Progress section: move items from "In Progress" to "Done" when completed +- UPDATE "Next Steps" based on what was accomplished +- PRESERVE exact file paths, function names, and error messages +- If something is no longer relevant, you may remove it + +Use this EXACT format: + +## Goal +[Preserve existing goals, add new ones if the task expanded] + +## Constraints & Preferences +- [Preserve existing, add new ones discovered] + +## Progress +### Done +- [x] [Include previously done items AND newly completed items] + +### In Progress +- [ ] [Current work - update based on progress] + +### Blocked +- [Current blockers - remove if resolved] + +## Key Decisions +- **[Decision]**: [Brief rationale] (preserve all previous, add new) + +## Next Steps +1. [Update based on current state] + +## Critical Context +- [Preserve important context, add new if needed] + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; + +/** Generate or update a conversation summary for compaction. */ +export async function generateSummary( + currentMessages: AgentMessage[], + model: Model, + reserveTokens: number, + apiKey: string, + headers?: Record, + signal?: AbortSignal, + customInstructions?: string, + previousSummary?: string, + thinkingLevel?: ThinkingLevel, +): Promise> { + const maxTokens = Math.min( + Math.floor(0.8 * reserveTokens), + model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, + ); + let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT; + if (customInstructions) { + basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`; + } + const llmMessages = convertToLlm(currentMessages); + const conversationText = serializeConversation(llmMessages); + let promptText = `\n${conversationText}\n\n\n`; + if (previousSummary) { + promptText += `\n${previousSummary}\n\n\n`; + } + promptText += basePrompt; + + const summarizationMessages = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: promptText }], + timestamp: Date.now(), + }, + ]; + + const completionOptions = + model.reasoning && thinkingLevel && thinkingLevel !== "off" + ? { maxTokens, signal, apiKey, headers, reasoning: thinkingLevel } + : { maxTokens, signal, apiKey, headers }; + + const response = await completeSimple( + model, + { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + completionOptions, + ); + if (response.stopReason === "aborted") { + return err(new CompactionError("aborted", response.errorMessage || "Summarization aborted")); + } + if (response.stopReason === "error") { + return err( + new CompactionError( + "summarization_failed", + `Summarization failed: ${response.errorMessage || "Unknown error"}`, + ), + ); + } + + const textContent = response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); + + return ok(textContent); +} + +/** Prepared inputs for a compaction run. */ +export interface CompactionPreparation { + /** Entry id where retained history starts. */ + firstKeptEntryId: string; + /** Messages summarized into the history summary. */ + messagesToSummarize: AgentMessage[]; + /** Prefix messages summarized separately when compaction splits a turn. */ + turnPrefixMessages: AgentMessage[]; + /** Whether compaction splits a turn. */ + isSplitTurn: boolean; + /** Estimated context tokens before compaction. */ + tokensBefore: number; + /** Previous compaction summary used for iterative updates. */ + previousSummary?: string; + /** File operations extracted from summarized history. */ + fileOps: FileOperations; + /** Settings used to prepare compaction. */ + settings: CompactionSettings; +} + +/** Prepare session entries for compaction, or return undefined when compaction is not applicable. */ +export function prepareCompaction( + pathEntries: SessionTreeEntry[], + settings: CompactionSettings, +): Result { + if (pathEntries.length === 0 || pathEntries[pathEntries.length - 1].type === "compaction") { + return ok(undefined); + } + + let prevCompactionIndex = -1; + for (let i = pathEntries.length - 1; i >= 0; i--) { + if (pathEntries[i].type === "compaction") { + prevCompactionIndex = i; + break; + } + } + + let previousSummary: string | undefined; + let boundaryStart = 0; + if (prevCompactionIndex >= 0) { + const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry; + previousSummary = prevCompaction.summary; + const firstKeptEntryIndex = pathEntries.findIndex( + (entry) => entry.id === prevCompaction.firstKeptEntryId, + ); + boundaryStart = firstKeptEntryIndex >= 0 ? firstKeptEntryIndex : prevCompactionIndex + 1; + } + const boundaryEnd = pathEntries.length; + + const tokensBefore = estimateContextTokens(buildSessionContext(pathEntries).messages).tokens; + + const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens); + const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex]; + if (!firstKeptEntry?.id) { + return err( + new CompactionError( + "invalid_session", + "First kept entry has no UUID - session may need migration", + ), + ); + } + const firstKeptEntryId = firstKeptEntry.id; + + const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex; + const messagesToSummarize: AgentMessage[] = []; + for (let i = boundaryStart; i < historyEnd; i++) { + const msg = getMessageFromEntryForCompaction(pathEntries[i]); + if (msg) { + messagesToSummarize.push(msg); + } + } + const turnPrefixMessages: AgentMessage[] = []; + if (cutPoint.isSplitTurn) { + for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) { + const msg = getMessageFromEntryForCompaction(pathEntries[i]); + if (msg) { + turnPrefixMessages.push(msg); + } + } + } + const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex); + if (cutPoint.isSplitTurn) { + for (const msg of turnPrefixMessages) { + extractFileOpsFromMessage(msg, fileOps); + } + } + + return ok({ + firstKeptEntryId, + messagesToSummarize, + turnPrefixMessages, + isSplitTurn: cutPoint.isSplitTurn, + tokensBefore, + previousSummary, + fileOps, + settings, + }); +} + +const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained. + +Summarize the prefix to provide context for the retained suffix: + +## Original Request +[What did the user ask for in this turn?] + +## Early Progress +- [Key decisions and work done in the prefix] + +## Context for Suffix +- [Information needed to understand the retained recent work] + +Be concise. Focus on what's needed to understand the kept suffix.`; + +export { serializeConversation } from "./utils.js"; + +/** Generate compaction summary data from prepared session history. */ +export async function compact( + preparation: CompactionPreparation, + model: Model, + apiKey: string, + headers?: Record, + customInstructions?: string, + signal?: AbortSignal, + thinkingLevel?: ThinkingLevel, +): Promise> { + const { + firstKeptEntryId, + messagesToSummarize, + turnPrefixMessages, + isSplitTurn, + tokensBefore, + previousSummary, + fileOps, + settings, + } = preparation; + + if (!firstKeptEntryId) { + return err( + new CompactionError( + "invalid_session", + "First kept entry has no UUID - session may need migration", + ), + ); + } + + let summary: string; + + if (isSplitTurn && turnPrefixMessages.length > 0) { + const [historyResult, turnPrefixResult] = await Promise.all([ + messagesToSummarize.length > 0 + ? generateSummary( + messagesToSummarize, + model, + settings.reserveTokens, + apiKey, + headers, + signal, + customInstructions, + previousSummary, + thinkingLevel, + ) + : Promise.resolve(ok("No prior history.")), + generateTurnPrefixSummary( + turnPrefixMessages, + model, + settings.reserveTokens, + apiKey, + headers, + signal, + thinkingLevel, + ), + ]); + if (!historyResult.ok) { + return err(historyResult.error); + } + if (!turnPrefixResult.ok) { + return err(turnPrefixResult.error); + } + summary = `${historyResult.value}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value}`; + } else { + const summaryResult = await generateSummary( + messagesToSummarize, + model, + settings.reserveTokens, + apiKey, + headers, + signal, + customInstructions, + previousSummary, + thinkingLevel, + ); + if (!summaryResult.ok) { + return err(summaryResult.error); + } + summary = summaryResult.value; + } + + const { readFiles, modifiedFiles } = computeFileLists(fileOps); + summary += formatFileOperations(readFiles, modifiedFiles); + + return ok({ + summary, + firstKeptEntryId, + tokensBefore, + details: { readFiles, modifiedFiles } as CompactionDetails, + }); +} +async function generateTurnPrefixSummary( + messages: AgentMessage[], + model: Model, + reserveTokens: number, + apiKey: string, + headers?: Record, + signal?: AbortSignal, + thinkingLevel?: ThinkingLevel, +): Promise> { + const maxTokens = Math.min( + Math.floor(0.5 * reserveTokens), + model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, + ); + const llmMessages = convertToLlm(messages); + const conversationText = serializeConversation(llmMessages); + const promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`; + const summarizationMessages = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: promptText }], + timestamp: Date.now(), + }, + ]; + + const response = await completeSimple( + model, + { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + model.reasoning && thinkingLevel && thinkingLevel !== "off" + ? { maxTokens, signal, apiKey, headers, reasoning: thinkingLevel } + : { maxTokens, signal, apiKey, headers }, + ); + if (response.stopReason === "aborted") { + return err( + new CompactionError("aborted", response.errorMessage || "Turn prefix summarization aborted"), + ); + } + if (response.stopReason === "error") { + return err( + new CompactionError( + "summarization_failed", + `Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`, + ), + ); + } + + return ok( + response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"), + ); +} diff --git a/packages/agent-core/src/harness/compaction/utils.ts b/packages/agent-core/src/harness/compaction/utils.ts new file mode 100644 index 00000000000..f390d05fa62 --- /dev/null +++ b/packages/agent-core/src/harness/compaction/utils.ts @@ -0,0 +1,167 @@ +import type { Message } from "openclaw/plugin-sdk/llm"; +import type { AgentMessage } from "../../types.js"; + +/** File paths touched by a session branch or compaction range. */ +export interface FileOperations { + /** Files read but not necessarily modified. */ + read: Set; + /** Files written by full-file write operations. */ + written: Set; + /** Files modified by edit operations. */ + edited: Set; +} + +/** Create an empty file-operation accumulator. */ +export function createFileOps(): FileOperations { + return { + read: new Set(), + written: new Set(), + edited: new Set(), + }; +} + +/** Add file operations from assistant tool calls to an accumulator. */ +export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void { + if (message.role !== "assistant") { + return; + } + if (!("content" in message) || !Array.isArray(message.content)) { + return; + } + + for (const block of message.content) { + if (typeof block !== "object" || block === null) { + continue; + } + if (!("type" in block) || block.type !== "toolCall") { + continue; + } + if (!("arguments" in block) || !("name" in block)) { + continue; + } + + const args = block.arguments as Record | undefined; + if (!args) { + continue; + } + + const path = typeof args.path === "string" ? args.path : undefined; + if (!path) { + continue; + } + + switch (block.name) { + case "read": + fileOps.read.add(path); + break; + case "write": + fileOps.written.add(path); + break; + case "edit": + fileOps.edited.add(path); + break; + } + } +} + +/** Compute sorted read-only and modified file lists from accumulated operations. */ +export function computeFileLists(fileOps: FileOperations): { + readFiles: string[]; + modifiedFiles: string[]; +} { + const modified = new Set([...fileOps.edited, ...fileOps.written]); + const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).toSorted(); + const modifiedFiles = [...modified].toSorted(); + return { readFiles: readOnly, modifiedFiles }; +} + +/** Format file lists as summary metadata tags. */ +export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string { + const sections: string[] = []; + if (readFiles.length > 0) { + sections.push(`\n${readFiles.join("\n")}\n`); + } + if (modifiedFiles.length > 0) { + sections.push(`\n${modifiedFiles.join("\n")}\n`); + } + if (sections.length === 0) { + return ""; + } + return `\n\n${sections.join("\n\n")}`; +} + +const TOOL_RESULT_MAX_CHARS = 2000; + +function safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value) ?? "undefined"; + } catch { + return "[unserializable]"; + } +} + +function truncateForSummary(text: string, maxChars: number): string { + if (text.length <= maxChars) { + return text; + } + const truncatedChars = text.length - maxChars; + return `${text.slice(0, maxChars)}\n\n[... ${truncatedChars} more characters truncated]`; +} + +/** Serialize LLM messages to plain text for summarization prompts. */ +export function serializeConversation(messages: Message[]): string { + const parts: string[] = []; + + for (const msg of messages) { + if (msg.role === "user") { + const content = + typeof msg.content === "string" + ? msg.content + : msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + if (content) { + parts.push(`[User]: ${content}`); + } + } else if (msg.role === "assistant") { + const textParts: string[] = []; + const thinkingParts: string[] = []; + const toolCalls: string[] = []; + + for (const block of msg.content) { + if (block.type === "text") { + textParts.push(block.text); + } else if (block.type === "thinking") { + thinkingParts.push(block.thinking); + } else if (block.type === "toolCall") { + const args = block.arguments; + const argsStr = Object.entries(args) + .map(([k, v]) => `${k}=${safeJsonStringify(v)}`) + .join(", "); + toolCalls.push(`${block.name}(${argsStr})`); + } + } + + if (thinkingParts.length > 0) { + parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`); + } + if (textParts.length > 0) { + parts.push(`[Assistant]: ${textParts.join("\n")}`); + } + if (toolCalls.length > 0) { + parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`); + } + } else if (msg.role === "toolResult") { + const content = msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + if (content) { + parts.push(`[Tool result]: ${truncateForSummary(content, TOOL_RESULT_MAX_CHARS)}`); + } + } + } + + return parts.join("\n\n"); +} diff --git a/packages/agent-core/src/harness/env/nodejs.ts b/packages/agent-core/src/harness/env/nodejs.ts new file mode 100644 index 00000000000..d7474ef5424 --- /dev/null +++ b/packages/agent-core/src/harness/env/nodejs.ts @@ -0,0 +1,622 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { constants, createReadStream } from "node:fs"; +import { + access, + appendFile, + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { + type ExecutionEnv, + ExecutionError, + err, + FileError, + type FileInfo, + type FileKind, + ok, + type Result, + toError, +} from "../types.js"; + +function resolvePath(cwd: string, path: string): string { + return isAbsolute(path) ? path : resolve(cwd, path); +} + +function fileKindFromStats(stats: { + isFile(): boolean; + isDirectory(): boolean; + isSymbolicLink(): boolean; +}): FileKind | undefined { + if (stats.isFile()) { + return "file"; + } + if (stats.isDirectory()) { + return "directory"; + } + if (stats.isSymbolicLink()) { + return "symlink"; + } + return undefined; +} + +function fileInfoFromStats( + path: string, + stats: { + isFile(): boolean; + isDirectory(): boolean; + isSymbolicLink(): boolean; + size: number; + mtimeMs: number; + }, +): Result { + const kind = fileKindFromStats(stats); + if (!kind) { + return err(new FileError("invalid", "Unsupported file type", path)); + } + return ok({ + name: path.replace(/\/+$/, "").split("/").pop() ?? path, + path, + kind, + size: stats.size, + mtimeMs: stats.mtimeMs, + }); +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + +function toFileError(error: unknown, path?: string): FileError { + if (error instanceof FileError) { + return error; + } + const cause = toError(error); + if (isNodeError(error)) { + const message = error.message; + switch (error.code) { + case "ABORT_ERR": + return new FileError("aborted", message, path, cause); + case "ENOENT": + return new FileError("not_found", message, path, cause); + case "EACCES": + case "EPERM": + return new FileError("permission_denied", message, path, cause); + case "ENOTDIR": + return new FileError("not_directory", message, path, cause); + case "EISDIR": + return new FileError("is_directory", message, path, cause); + case "EINVAL": + return new FileError("invalid", message, path, cause); + default: + break; + } + } + return new FileError("unknown", cause.message, path, cause); +} + +function abortResult( + signal: AbortSignal | undefined, + path?: string, +): Result | undefined { + return signal?.aborted ? err(new FileError("aborted", "aborted", path)) : undefined; +} + +async function pathExists(path: string): Promise { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +async function runCommand( + command: string, + args: string[], + timeoutMs: number, +): Promise<{ stdout: string; status: number | null }> { + return await new Promise((resolve) => { + let stdout = ""; + let child: ReturnType; + try { + child = spawn(command, args, { + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + }); + } catch { + resolve({ stdout: "", status: null }); + return; + } + const timeout = setTimeout(() => { + if (child.pid) { + killProcessTree(child.pid); + } + }, timeoutMs); + child.stdout?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + stdout += chunk; + }); + child.on("error", () => { + clearTimeout(timeout); + resolve({ stdout: "", status: null }); + }); + child.on("close", (status) => { + clearTimeout(timeout); + resolve({ stdout, status }); + }); + }); +} + +async function findBashOnPath(): Promise { + const result = + process.platform === "win32" + ? await runCommand("where", ["bash.exe"], 5000) + : await runCommand("which", ["bash"], 5000); + if (result.status !== 0 || !result.stdout) { + return null; + } + const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; + return firstMatch && (await pathExists(firstMatch)) ? firstMatch : null; +} + +async function getShellConfig( + customShellPath?: string, +): Promise> { + if (customShellPath) { + if (await pathExists(customShellPath)) { + return ok({ shell: customShellPath, args: ["-c"] }); + } + return err( + new ExecutionError("shell_unavailable", `Custom shell path not found: ${customShellPath}`), + ); + } + if (process.platform === "win32") { + const candidates: string[] = []; + const programFiles = process.env.ProgramFiles; + if (programFiles) { + candidates.push(`${programFiles}\\Git\\bin\\bash.exe`); + } + const programFilesX86 = process.env["ProgramFiles(x86)"]; + if (programFilesX86) { + candidates.push(`${programFilesX86}\\Git\\bin\\bash.exe`); + } + for (const candidate of candidates) { + if (await pathExists(candidate)) { + return ok({ shell: candidate, args: ["-c"] }); + } + } + const bashOnPath = await findBashOnPath(); + if (bashOnPath) { + return ok({ shell: bashOnPath, args: ["-c"] }); + } + return err(new ExecutionError("shell_unavailable", "No bash shell found")); + } + + if (await pathExists("/bin/bash")) { + return ok({ shell: "/bin/bash", args: ["-c"] }); + } + const bashOnPath = await findBashOnPath(); + if (bashOnPath) { + return ok({ shell: bashOnPath, args: ["-c"] }); + } + return ok({ shell: "sh", args: ["-c"] }); +} + +function getShellEnv( + baseEnv?: NodeJS.ProcessEnv, + extraEnv?: Record, +): NodeJS.ProcessEnv { + return { + ...process.env, + ...baseEnv, + ...extraEnv, + }; +} + +function killProcessTree(pid: number): void { + if (process.platform === "win32") { + try { + spawn("taskkill", ["/F", "/T", "/PID", String(pid)], { + stdio: "ignore", + detached: true, + windowsHide: true, + }); + } catch { + // Ignore errors. + } + return; + } + + try { + process.kill(-pid, "SIGKILL"); + } catch { + try { + process.kill(pid, "SIGKILL"); + } catch { + // Process already dead. + } + } +} + +export class NodeExecutionEnv implements ExecutionEnv { + cwd: string; + private shellPath?: string; + private shellEnv?: NodeJS.ProcessEnv; + + constructor(options: { cwd: string; shellPath?: string; shellEnv?: NodeJS.ProcessEnv }) { + this.cwd = options.cwd; + this.shellPath = options.shellPath; + this.shellEnv = options.shellEnv; + } + + async absolutePath(path: string): Promise> { + return ok(resolvePath(this.cwd, path)); + } + + async joinPath(parts: string[]): Promise> { + return ok(join(...parts)); + } + + async exec( + command: string, + options?: { + cwd?: string; + env?: Record; + timeout?: number; + abortSignal?: AbortSignal; + onStdout?: (chunk: string) => void; + onStderr?: (chunk: string) => void; + }, + ): Promise> { + if (options?.abortSignal?.aborted) { + return err(new ExecutionError("aborted", "aborted")); + } + + const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd; + const shellConfig = await getShellConfig(this.shellPath); + if (!shellConfig.ok) { + return shellConfig; + } + + return await new Promise((resolvePromise) => { + let stdout = ""; + let stderr = ""; + let settled = false; + let timedOut = false; + let callbackError: ExecutionError | undefined; + let child: ReturnType | undefined; + let timeoutId: ReturnType | undefined; + + const onAbort = () => { + if (child?.pid) { + killProcessTree(child.pid); + } + }; + + const settle = ( + result: Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>, + ) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + if (options?.abortSignal) { + options.abortSignal.removeEventListener("abort", onAbort); + } + if (settled) { + return; + } + settled = true; + resolvePromise(result); + }; + + try { + child = spawn(shellConfig.value.shell, [...shellConfig.value.args, command], { + cwd, + detached: process.platform !== "win32", + env: getShellEnv(this.shellEnv, options?.env), + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + } catch (error) { + const cause = toError(error); + settle(err(new ExecutionError("spawn_error", cause.message, cause))); + return; + } + + timeoutId = + typeof options?.timeout === "number" + ? setTimeout(() => { + timedOut = true; + if (child?.pid) { + killProcessTree(child.pid); + } + }, options.timeout * 1000) + : undefined; + + if (options?.abortSignal) { + if (options.abortSignal.aborted) { + onAbort(); + } else { + options.abortSignal.addEventListener("abort", onAbort, { once: true }); + } + } + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + stdout += chunk; + try { + options?.onStdout?.(chunk); + } catch (error) { + const cause = toError(error); + callbackError = new ExecutionError("callback_error", cause.message, cause); + onAbort(); + } + }); + child.stderr?.on("data", (chunk: string) => { + stderr += chunk; + try { + options?.onStderr?.(chunk); + } catch (error) { + const cause = toError(error); + callbackError = new ExecutionError("callback_error", cause.message, cause); + onAbort(); + } + }); + + child.on("error", (error) => { + settle(err(new ExecutionError("spawn_error", error.message, error))); + }); + + child.on("close", (code) => { + if (callbackError) { + settle(err(callbackError)); + return; + } + if (timedOut) { + settle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`))); + return; + } + if (options?.abortSignal?.aborted) { + settle(err(new ExecutionError("aborted", "aborted"))); + return; + } + settle(ok({ stdout, stderr, exitCode: code ?? 0 })); + }); + }); + } + + async readTextFile(path: string, abortSignal?: AbortSignal): Promise> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(abortSignal, resolved); + if (aborted) { + return aborted; + } + try { + return ok(await readFile(resolved, { encoding: "utf8", signal: abortSignal })); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async readTextLines( + path: string, + options?: { maxLines?: number; abortSignal?: AbortSignal }, + ): Promise> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(options?.abortSignal, resolved); + if (aborted) { + return aborted; + } + if (options?.maxLines !== undefined && options.maxLines <= 0) { + return ok([]); + } + let stream: ReturnType | undefined; + let lineReader: ReturnType | undefined; + try { + stream = createReadStream(resolved, { encoding: "utf8", signal: options?.abortSignal }); + lineReader = createInterface({ input: stream, crlfDelay: Infinity }); + const lines: string[] = []; + for await (const line of lineReader) { + const loopAbort = abortResult(options?.abortSignal, resolved); + if (loopAbort) { + return loopAbort; + } + lines.push(line); + if (options?.maxLines !== undefined && lines.length >= options.maxLines) { + break; + } + } + const afterReadAbort = abortResult(options?.abortSignal, resolved); + if (afterReadAbort) { + return afterReadAbort; + } + return ok(lines); + } catch (error) { + return err(toFileError(error, resolved)); + } finally { + lineReader?.close(); + stream?.destroy(); + } + } + + async readBinaryFile( + path: string, + abortSignal?: AbortSignal, + ): Promise> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(abortSignal, resolved); + if (aborted) { + return aborted; + } + try { + return ok(await readFile(resolved, { signal: abortSignal })); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async writeFile( + path: string, + content: string | Uint8Array, + abortSignal?: AbortSignal, + ): Promise> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(abortSignal, resolved); + if (aborted) { + return aborted; + } + try { + await mkdir(resolve(resolved, ".."), { recursive: true }); + const afterMkdirAbort = abortResult(abortSignal, resolved); + if (afterMkdirAbort) { + return afterMkdirAbort; + } + await writeFile(resolved, content, { signal: abortSignal }); + return ok(undefined); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async appendFile(path: string, content: string | Uint8Array): Promise> { + const resolved = resolvePath(this.cwd, path); + try { + await mkdir(resolve(resolved, ".."), { recursive: true }); + await appendFile(resolved, content); + return ok(undefined); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async fileInfo(path: string): Promise> { + const resolved = resolvePath(this.cwd, path); + try { + return fileInfoFromStats(resolved, await lstat(resolved)); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async listDir(path: string, abortSignal?: AbortSignal): Promise> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(abortSignal, resolved); + if (aborted) { + return aborted; + } + try { + const entries = await readdir(resolved, { withFileTypes: true }); + const infos: FileInfo[] = []; + for (const entry of entries) { + const loopAbort = abortResult(abortSignal, resolved); + if (loopAbort) { + return loopAbort; + } + const entryPath = resolve(resolved, entry.name); + try { + const info = fileInfoFromStats(entryPath, await lstat(entryPath)); + if (info.ok) { + infos.push(info.value); + } + } catch (error) { + return err(toFileError(error, entryPath)); + } + } + return ok(infos); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async canonicalPath(path: string): Promise> { + const resolved = resolvePath(this.cwd, path); + try { + return ok(await realpath(resolved)); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async exists(path: string): Promise> { + const result = await this.fileInfo(path); + if (result.ok) { + return ok(true); + } + if (result.error.code === "not_found") { + return ok(false); + } + return err(result.error); + } + + async createDir( + path: string, + options?: { recursive?: boolean }, + ): Promise> { + const resolved = resolvePath(this.cwd, path); + try { + await mkdir(resolved, { recursive: options?.recursive ?? true }); + return ok(undefined); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async remove( + path: string, + options?: { recursive?: boolean; force?: boolean }, + ): Promise> { + const resolved = resolvePath(this.cwd, path); + try { + await rm(resolved, { + recursive: options?.recursive ?? false, + force: options?.force ?? false, + }); + return ok(undefined); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async createTempDir(prefix: string = "tmp-"): Promise> { + try { + return ok(await mkdtemp(join(tmpdir(), prefix))); + } catch (error) { + return err(toFileError(error)); + } + } + + async createTempFile(options?: { + prefix?: string; + suffix?: string; + }): Promise> { + const dir = await this.createTempDir("tmp-"); + if (!dir.ok) { + return dir; + } + const filePath = join( + dir.value, + `${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`, + ); + try { + await writeFile(filePath, ""); + return ok(filePath); + } catch (error) { + return err(toFileError(error, filePath)); + } + } + + async cleanup(): Promise { + // nothing to clean up for the local node implementation + } +} diff --git a/packages/agent-core/src/harness/messages.ts b/packages/agent-core/src/harness/messages.ts new file mode 100644 index 00000000000..ab694f25d24 --- /dev/null +++ b/packages/agent-core/src/harness/messages.ts @@ -0,0 +1,179 @@ +import type { ImageContent, Message, TextContent } from "openclaw/plugin-sdk/llm"; +import type { AgentMessage } from "../types.js"; + +export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary: + + +`; + +export const COMPACTION_SUMMARY_SUFFIX = ` +`; + +export const BRANCH_SUMMARY_PREFIX = `The following is a summary of a branch that this conversation came back from: + + +`; + +export const BRANCH_SUMMARY_SUFFIX = ``; + +export interface BashExecutionMessage { + role: "bashExecution"; + command: string; + output: string; + exitCode: number | undefined; + cancelled: boolean; + truncated: boolean; + fullOutputPath?: string; + timestamp: number; + excludeFromContext?: boolean; +} + +export interface CustomMessage { + role: "custom"; + customType: string; + content: string | (TextContent | ImageContent)[]; + display: boolean; + details?: T; + timestamp: number; +} + +export interface BranchSummaryMessage { + role: "branchSummary"; + summary: string; + fromId: string; + timestamp: number; +} + +export interface CompactionSummaryMessage { + role: "compactionSummary"; + summary: string; + tokensBefore: number; + timestamp: number; +} + +declare module "../types.js" { + interface CustomAgentMessages { + bashExecution: BashExecutionMessage; + custom: CustomMessage; + branchSummary: BranchSummaryMessage; + compactionSummary: CompactionSummaryMessage; + } +} + +export function bashExecutionToText(msg: BashExecutionMessage): string { + let text = `Ran \`${msg.command}\`\n`; + if (msg.output) { + text += `\`\`\`\n${msg.output}\n\`\`\``; + } else { + text += "(no output)"; + } + if (msg.cancelled) { + text += "\n\n(command cancelled)"; + } else if (msg.exitCode !== null && msg.exitCode !== undefined && msg.exitCode !== 0) { + text += `\n\nCommand exited with code ${msg.exitCode}`; + } + if (msg.truncated && msg.fullOutputPath) { + text += `\n\n[Output truncated. Full output: ${msg.fullOutputPath}]`; + } + return text; +} + +export function createBranchSummaryMessage( + summary: string, + fromId: string, + timestamp: string, +): BranchSummaryMessage { + return { + role: "branchSummary", + summary, + fromId, + timestamp: new Date(timestamp).getTime(), + }; +} + +export function createCompactionSummaryMessage( + summary: string, + tokensBefore: number, + timestamp: string, +): CompactionSummaryMessage { + return { + role: "compactionSummary", + summary, + tokensBefore, + timestamp: new Date(timestamp).getTime(), + }; +} + +export function createCustomMessage( + customType: string, + content: string | (TextContent | ImageContent)[], + display: boolean, + details: unknown, + timestamp: string, +): CustomMessage { + return { + role: "custom", + customType, + content, + display, + details, + timestamp: new Date(timestamp).getTime(), + }; +} + +export function convertToLlm(messages: AgentMessage[]): Message[] { + return messages + .map((m): Message | undefined => { + switch (m.role) { + case "bashExecution": + if (m.excludeFromContext) { + return undefined; + } + return { + role: "user", + content: [{ type: "text", text: bashExecutionToText(m) }], + timestamp: m.timestamp, + }; + case "custom": { + const content = + typeof m.content === "string" + ? [{ type: "text" as const, text: m.content }] + : m.content; + return { + role: "user", + content, + timestamp: m.timestamp, + }; + } + case "branchSummary": + return { + role: "user", + content: [ + { + type: "text" as const, + text: BRANCH_SUMMARY_PREFIX + m.summary + BRANCH_SUMMARY_SUFFIX, + }, + ], + timestamp: m.timestamp, + }; + case "compactionSummary": + return { + role: "user", + content: [ + { + type: "text" as const, + text: COMPACTION_SUMMARY_PREFIX + m.summary + COMPACTION_SUMMARY_SUFFIX, + }, + ], + timestamp: m.timestamp, + }; + case "user": + case "assistant": + case "toolResult": + return m; + default: + return undefined; + } + }) + .filter((m): m is Message => m !== undefined); +} diff --git a/packages/agent-core/src/harness/prompt-templates.ts b/packages/agent-core/src/harness/prompt-templates.ts new file mode 100644 index 00000000000..8b5776e298b --- /dev/null +++ b/packages/agent-core/src/harness/prompt-templates.ts @@ -0,0 +1,319 @@ +import { parse } from "yaml"; +import { + type ExecutionEnv, + type FileInfo, + type PromptTemplate, + type Result, + toError, +} from "./types.js"; + +export type PromptTemplateDiagnosticCode = + | "file_info_failed" + | "list_failed" + | "read_failed" + | "parse_failed"; + +/** Warning produced while loading prompt templates. */ +export interface PromptTemplateDiagnostic { + /** Diagnostic severity. Currently only warnings are emitted. */ + type: "warning"; + /** Stable diagnostic code. */ + code: PromptTemplateDiagnosticCode; + /** Human-readable diagnostic message. */ + message: string; + /** Path associated with the diagnostic. */ + path: string; +} + +interface PromptTemplateFrontmatter { + description?: string; + "argument-hint"?: string; + [key: string]: unknown; +} + +/** + * Load prompt templates from one or more paths. + * + * Directory inputs load direct `.md` children non-recursively. File inputs load explicit `.md` files. Missing paths and + * non-markdown files are skipped. Read and parse failures are returned as diagnostics. + */ +export async function loadPromptTemplates( + env: ExecutionEnv, + paths: string | string[], +): Promise<{ promptTemplates: PromptTemplate[]; diagnostics: PromptTemplateDiagnostic[] }> { + const promptTemplates: PromptTemplate[] = []; + const diagnostics: PromptTemplateDiagnostic[] = []; + for (const path of Array.isArray(paths) ? paths : [paths]) { + const infoResult = await env.fileInfo(path); + if (!infoResult.ok) { + if (infoResult.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: infoResult.error.message, + path, + }); + } + continue; + } + const info = infoResult.value; + const kind = await resolveKind(env, info, diagnostics); + if (kind === "directory") { + const result = await loadTemplatesFromDir(env, info.path); + promptTemplates.push(...result.promptTemplates); + diagnostics.push(...result.diagnostics); + } else if (kind === "file" && info.name.endsWith(".md")) { + const result = await loadTemplateFromFile(env, info.path); + if (result.promptTemplate) { + promptTemplates.push(result.promptTemplate); + } + diagnostics.push(...result.diagnostics); + } + } + return { promptTemplates, diagnostics }; +} + +/** + * Load prompt templates from source-tagged paths. + * + * Source values are preserved exactly and attached to every loaded prompt template and diagnostic. The agent package does + * not interpret source values; applications define their own provenance shape. + */ +export async function loadSourcedPromptTemplates< + TSource, + TPromptTemplate extends PromptTemplate = PromptTemplate, +>( + env: ExecutionEnv, + inputs: Array<{ path: string; source: TSource }>, + mapPromptTemplate?: (promptTemplate: PromptTemplate, source: TSource) => TPromptTemplate, +): Promise<{ + promptTemplates: Array<{ promptTemplate: TPromptTemplate; source: TSource }>; + diagnostics: Array; +}> { + const promptTemplates: Array<{ promptTemplate: TPromptTemplate; source: TSource }> = []; + const diagnostics: Array = []; + for (const input of inputs) { + const result = await loadPromptTemplates(env, input.path); + for (const promptTemplate of result.promptTemplates) { + promptTemplates.push({ + promptTemplate: mapPromptTemplate + ? mapPromptTemplate(promptTemplate, input.source) + : (promptTemplate as TPromptTemplate), + source: input.source, + }); + } + for (const diagnostic of result.diagnostics) { + diagnostics.push({ ...diagnostic, source: input.source }); + } + } + return { promptTemplates, diagnostics }; +} + +async function loadTemplatesFromDir( + env: ExecutionEnv, + dir: string, +): Promise<{ promptTemplates: PromptTemplate[]; diagnostics: PromptTemplateDiagnostic[] }> { + const promptTemplates: PromptTemplate[] = []; + const diagnostics: PromptTemplateDiagnostic[] = []; + const entriesResult = await env.listDir(dir); + if (!entriesResult.ok) { + diagnostics.push({ + type: "warning", + code: "list_failed", + message: entriesResult.error.message, + path: dir, + }); + return { promptTemplates, diagnostics }; + } + const entries = entriesResult.value; + + for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) { + const kind = await resolveKind(env, entry, diagnostics); + if (kind !== "file" || !entry.name.endsWith(".md")) { + continue; + } + const result = await loadTemplateFromFile(env, entry.path); + if (result.promptTemplate) { + promptTemplates.push(result.promptTemplate); + } + diagnostics.push(...result.diagnostics); + } + return { promptTemplates, diagnostics }; +} + +async function loadTemplateFromFile( + env: ExecutionEnv, + filePath: string, +): Promise<{ promptTemplate: PromptTemplate | null; diagnostics: PromptTemplateDiagnostic[] }> { + const diagnostics: PromptTemplateDiagnostic[] = []; + const rawContent = await env.readTextFile(filePath); + if (!rawContent.ok) { + diagnostics.push({ + type: "warning", + code: "read_failed", + message: rawContent.error.message, + path: filePath, + }); + return { promptTemplate: null, diagnostics }; + } + + const parsed = parseFrontmatter(rawContent.value) as Result< + { frontmatter: PromptTemplateFrontmatter; body: string }, + Error + >; + if (!parsed.ok) { + diagnostics.push({ + type: "warning", + code: "parse_failed", + message: parsed.error.message, + path: filePath, + }); + return { promptTemplate: null, diagnostics }; + } + + const { frontmatter, body } = parsed.value; + const firstLine = body.split("\n").find((line) => line.trim()); + let description = typeof frontmatter.description === "string" ? frontmatter.description : ""; + if (!description && firstLine) { + description = firstLine.slice(0, 60); + if (firstLine.length > 60) { + description += "..."; + } + } + return { + promptTemplate: { + name: basenameEnvPath(filePath).replace(/\.md$/i, ""), + description, + content: body, + }, + diagnostics, + }; +} + +async function resolveKind( + env: ExecutionEnv, + info: FileInfo, + diagnostics: PromptTemplateDiagnostic[], +): Promise<"file" | "directory" | undefined> { + if (info.kind === "file" || info.kind === "directory") { + return info.kind; + } + const canonicalPath = await env.canonicalPath(info.path); + if (!canonicalPath.ok) { + if (canonicalPath.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: canonicalPath.error.message, + path: info.path, + }); + } + return undefined; + } + const target = await env.fileInfo(canonicalPath.value); + if (!target.ok) { + if (target.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: target.error.message, + path: info.path, + }); + } + return undefined; + } + return target.value.kind === "file" || target.value.kind === "directory" + ? target.value.kind + : undefined; +} + +function parseFrontmatter( + content: string, +): Result<{ frontmatter: Record; body: string }, Error> { + try { + const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (!normalized.startsWith("---")) { + return { ok: true, value: { frontmatter: {}, body: normalized } }; + } + const endIndex = normalized.indexOf("\n---", 3); + if (endIndex === -1) { + return { ok: true, value: { frontmatter: {}, body: normalized } }; + } + const yamlString = normalized.slice(4, endIndex); + const body = normalized.slice(endIndex + 4).trim(); + return { + ok: true, + value: { frontmatter: (parse(yamlString) ?? {}) as Record, body }, + }; + } catch (error) { + return { ok: false, error: toError(error) }; + } +} + +function basenameEnvPath(path: string): string { + const normalized = path.replace(/\/+$/, ""); + const slashIndex = normalized.lastIndexOf("/"); + return slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1); +} + +/** Parse an argument string using simple shell-style single and double quotes. */ +export function parseCommandArgs(argsString: string): string[] { + const args: string[] = []; + let current = ""; + let inQuote: string | null = null; + + for (let i = 0; i < argsString.length; i++) { + const char = argsString[i]; + if (inQuote) { + if (char === inQuote) { + inQuote = null; + } else { + current += char; + } + } else if (char === '"' || char === "'") { + inQuote = char; + } else if (char === " " || char === "\t") { + if (current) { + args.push(current); + current = ""; + } + } else { + current += char; + } + } + if (current) { + args.push(current); + } + return args; +} + +/** Substitute prompt template placeholders (`$1`, `$@`, `$ARGUMENTS`, `${@:N}`, `${@:N:L}`) with command arguments. */ +export function substituteArgs(content: string, args: string[]): string { + let result = content; + result = result.replace(/\$(\d+)/g, (_, num: string) => args[Number.parseInt(num, 10) - 1] ?? ""); + result = result.replace( + /\$\{@:(\d+)(?::(\d+))?\}/g, + (_, startStr: string, lengthStr?: string) => { + let start = Number.parseInt(startStr, 10) - 1; + if (start < 0) { + start = 0; + } + if (lengthStr) { + return args.slice(start, start + Number.parseInt(lengthStr, 10)).join(" "); + } + return args.slice(start).join(" "); + }, + ); + const allArgs = args.join(" "); + result = result.replace(/\$ARGUMENTS/g, allArgs); + result = result.replace(/\$@/g, allArgs); + return result; +} + +/** Format a prompt template invocation with positional arguments. */ +export function formatPromptTemplateInvocation( + template: PromptTemplate, + args: string[] = [], +): string { + return substituteArgs(template.content, args); +} diff --git a/packages/agent-core/src/harness/session/jsonl-repo.ts b/packages/agent-core/src/harness/session/jsonl-repo.ts new file mode 100644 index 00000000000..98e210cc4a5 --- /dev/null +++ b/packages/agent-core/src/harness/session/jsonl-repo.ts @@ -0,0 +1,197 @@ +import type { + FileSystem, + JsonlSessionCreateOptions, + JsonlSessionListOptions, + JsonlSessionMetadata, + JsonlSessionRepoApi, + Session, +} from "../types.js"; +import { SessionError, toError } from "../types.js"; +import { JsonlSessionStorage, loadJsonlSessionMetadata } from "./jsonl-storage.js"; +import { + createSessionId, + createTimestamp, + getEntriesToFork, + getFileSystemResultOrThrow, + toSession, +} from "./repo-utils.js"; + +type JsonlSessionRepoFileSystem = Pick< + FileSystem, + | "cwd" + | "absolutePath" + | "joinPath" + | "readTextFile" + | "readTextLines" + | "writeFile" + | "appendFile" + | "listDir" + | "exists" + | "createDir" + | "remove" +>; + +function encodeCwd(cwd: string): string { + return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; +} + +export class JsonlSessionRepo implements JsonlSessionRepoApi { + private readonly fs: JsonlSessionRepoFileSystem; + private readonly sessionsRootInput: string; + private sessionsRoot: string | undefined; + + constructor(options: { fs: JsonlSessionRepoFileSystem; sessionsRoot: string }) { + this.fs = options.fs; + this.sessionsRootInput = options.sessionsRoot; + } + + private async getSessionsRoot(): Promise { + if (!this.sessionsRoot) { + this.sessionsRoot = getFileSystemResultOrThrow( + await this.fs.absolutePath(this.sessionsRootInput), + `Failed to resolve sessions root ${this.sessionsRootInput}`, + ); + } + return this.sessionsRoot; + } + + private async getSessionDir(cwd: string): Promise { + return getFileSystemResultOrThrow( + await this.fs.joinPath([await this.getSessionsRoot(), encodeCwd(cwd)]), + `Failed to resolve session directory for ${cwd}`, + ); + } + + private async createSessionFilePath( + cwd: string, + sessionId: string, + timestamp: string, + ): Promise { + return getFileSystemResultOrThrow( + await this.fs.joinPath([ + await this.getSessionDir(cwd), + `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`, + ]), + `Failed to resolve session file path for ${sessionId}`, + ); + } + + async create(options: JsonlSessionCreateOptions): Promise> { + const id = options.id ?? createSessionId(); + const createdAt = createTimestamp(); + const sessionDir = await this.getSessionDir(options.cwd); + getFileSystemResultOrThrow( + await this.fs.createDir(sessionDir, { recursive: true }), + `Failed to create session directory ${sessionDir}`, + ); + const filePath = await this.createSessionFilePath(options.cwd, id, createdAt); + const storage = await JsonlSessionStorage.create(this.fs, filePath, { + cwd: options.cwd, + sessionId: id, + parentSessionPath: options.parentSessionPath, + }); + return toSession(storage); + } + + async open(metadata: JsonlSessionMetadata): Promise> { + if ( + !getFileSystemResultOrThrow( + await this.fs.exists(metadata.path), + `Failed to check session ${metadata.path}`, + ) + ) { + throw new SessionError("not_found", `Session not found: ${metadata.path}`); + } + const storage = await JsonlSessionStorage.open(this.fs, metadata.path); + return toSession(storage); + } + + async list(options: JsonlSessionListOptions = {}): Promise { + const dirs = options.cwd + ? [await this.getSessionDir(options.cwd)] + : await this.listSessionDirs(); + const sessions: JsonlSessionMetadata[] = []; + for (const dir of dirs) { + if ( + !getFileSystemResultOrThrow( + await this.fs.exists(dir), + `Failed to check session directory ${dir}`, + ) + ) { + continue; + } + const files = getFileSystemResultOrThrow( + await this.fs.listDir(dir), + `Failed to list sessions in ${dir}`, + ).filter((file) => file.kind !== "directory" && file.name.endsWith(".jsonl")); + for (const file of files) { + try { + sessions.push(await loadJsonlSessionMetadata(this.fs, file.path)); + } catch (error) { + const cause = toError(error); + if (!(cause instanceof SessionError) || cause.code !== "invalid_session") { + throw cause; + } + } + } + } + sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + return sessions; + } + + async delete(metadata: JsonlSessionMetadata): Promise { + getFileSystemResultOrThrow( + await this.fs.remove(metadata.path, { force: true }), + `Failed to delete session ${metadata.path}`, + ); + } + + async fork( + sourceMetadata: JsonlSessionMetadata, + options: JsonlSessionCreateOptions & { + entryId?: string; + position?: "before" | "at"; + id?: string; + }, + ): Promise> { + const source = await this.open(sourceMetadata); + const forkedEntries = await getEntriesToFork(source.getStorage(), options); + const id = options.id ?? createSessionId(); + const createdAt = createTimestamp(); + const sessionDir = await this.getSessionDir(options.cwd); + getFileSystemResultOrThrow( + await this.fs.createDir(sessionDir, { recursive: true }), + `Failed to create session directory ${sessionDir}`, + ); + const storage = await JsonlSessionStorage.create( + this.fs, + await this.createSessionFilePath(options.cwd, id, createdAt), + { + cwd: options.cwd, + sessionId: id, + parentSessionPath: options.parentSessionPath ?? sourceMetadata.path, + }, + ); + for (const entry of forkedEntries) { + await storage.appendEntry(entry); + } + return toSession(storage); + } + + private async listSessionDirs(): Promise { + const sessionsRoot = await this.getSessionsRoot(); + if ( + !getFileSystemResultOrThrow( + await this.fs.exists(sessionsRoot), + `Failed to check sessions root ${sessionsRoot}`, + ) + ) { + return []; + } + const entries = getFileSystemResultOrThrow( + await this.fs.listDir(sessionsRoot), + `Failed to list sessions root ${sessionsRoot}`, + ); + return entries.filter((entry) => entry.kind === "directory").map((entry) => entry.path); + } +} diff --git a/packages/agent-core/src/harness/session/jsonl-storage.ts b/packages/agent-core/src/harness/session/jsonl-storage.ts new file mode 100644 index 00000000000..805a84c83a1 --- /dev/null +++ b/packages/agent-core/src/harness/session/jsonl-storage.ts @@ -0,0 +1,349 @@ +import type { + FileSystem, + JsonlSessionMetadata, + LeafEntry, + SessionStorage, + SessionTreeEntry, +} from "../types.js"; +import { SessionError, toError } from "../types.js"; +import { getFileSystemResultOrThrow } from "./repo-utils.js"; +import { uuidv7 } from "./uuid.js"; + +type JsonlSessionStorageFileSystem = Pick< + FileSystem, + "readTextFile" | "readTextLines" | "writeFile" | "appendFile" +>; + +interface SessionHeader { + type: "session"; + version: 3; + id: string; + timestamp: string; + cwd: string; + parentSession?: string; +} + +function updateLabelCache(labelsById: Map, entry: SessionTreeEntry): void { + if (entry.type !== "label") { + return; + } + const label = entry.label?.trim(); + if (label) { + labelsById.set(entry.targetId, label); + } else { + labelsById.delete(entry.targetId); + } +} + +function buildLabelsById(entries: SessionTreeEntry[]): Map { + const labelsById = new Map(); + for (const entry of entries) { + updateLabelCache(labelsById, entry); + } + return labelsById; +} + +function generateEntryId(byId: { has(id: string): boolean }): string { + for (let i = 0; i < 100; i++) { + const id = uuidv7().slice(0, 8); + if (!byId.has(id)) { + return id; + } + } + return uuidv7(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function invalidSession(filePath: string, message: string, cause?: Error): SessionError { + return new SessionError( + "invalid_session", + `Invalid JSONL session file ${filePath}: ${message}`, + cause, + ); +} + +function invalidEntry( + filePath: string, + lineNumber: number, + message: string, + cause?: Error, +): SessionError { + return new SessionError( + "invalid_entry", + `Invalid JSONL session file ${filePath}: line ${lineNumber} ${message}`, + cause, + ); +} + +function parseHeaderLine(line: string, filePath: string): SessionHeader { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + throw invalidSession(filePath, "first line is not a valid session header", toError(error)); + } + if (!isRecord(parsed)) { + throw invalidSession(filePath, "first line is not a valid session header"); + } + if (parsed.type !== "session") { + throw invalidSession(filePath, "first line is not a valid session header"); + } + if (parsed.version !== 3) { + throw invalidSession(filePath, "unsupported session version"); + } + if (typeof parsed.id !== "string" || !parsed.id) { + throw invalidSession(filePath, "session header is missing id"); + } + if (typeof parsed.timestamp !== "string" || !parsed.timestamp) { + throw invalidSession(filePath, "session header is missing timestamp"); + } + if (typeof parsed.cwd !== "string" || !parsed.cwd) { + throw invalidSession(filePath, "session header is missing cwd"); + } + if (parsed.parentSession !== undefined && typeof parsed.parentSession !== "string") { + throw invalidSession(filePath, "session header parentSession must be a string"); + } + return { + type: "session", + version: 3, + id: parsed.id, + timestamp: parsed.timestamp, + cwd: parsed.cwd, + parentSession: parsed.parentSession, + }; +} + +function parseEntryLine(line: string, filePath: string, lineNumber: number): SessionTreeEntry { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + throw invalidEntry(filePath, lineNumber, "is not valid JSON", toError(error)); + } + if (!isRecord(parsed)) { + throw invalidEntry(filePath, lineNumber, "is not a valid session entry"); + } + if (typeof parsed.type !== "string") { + throw invalidEntry(filePath, lineNumber, "is missing entry type"); + } + if (typeof parsed.id !== "string" || !parsed.id) { + throw invalidEntry(filePath, lineNumber, "is missing entry id"); + } + if (parsed.parentId !== null && typeof parsed.parentId !== "string") { + throw invalidEntry(filePath, lineNumber, "has invalid parentId"); + } + if (typeof parsed.timestamp !== "string" || !parsed.timestamp) { + throw invalidEntry(filePath, lineNumber, "is missing timestamp"); + } + if (parsed.type === "leaf" && parsed.targetId !== null && typeof parsed.targetId !== "string") { + throw invalidEntry(filePath, lineNumber, "has invalid targetId"); + } + return parsed as unknown as SessionTreeEntry; +} + +function leafIdAfterEntry(entry: SessionTreeEntry): string | null { + return entry.type === "leaf" ? entry.targetId : entry.id; +} + +function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSessionMetadata { + return { + id: header.id, + createdAt: header.timestamp, + cwd: header.cwd, + path, + parentSessionPath: header.parentSession, + }; +} + +export async function loadJsonlSessionMetadata( + fs: JsonlSessionStorageFileSystem, + filePath: string, +): Promise { + const lines = getFileSystemResultOrThrow( + await fs.readTextLines(filePath, { maxLines: 1 }), + `Failed to read session header ${filePath}`, + ); + const line = lines[0]; + if (line?.trim()) { + return headerToSessionMetadata(parseHeaderLine(line, filePath), filePath); + } + throw invalidSession(filePath, "missing session header"); +} + +async function loadJsonlStorage( + fs: JsonlSessionStorageFileSystem, + filePath: string, +): Promise<{ + header: SessionHeader; + entries: SessionTreeEntry[]; + leafId: string | null; +}> { + const content = getFileSystemResultOrThrow( + await fs.readTextFile(filePath), + `Failed to read session ${filePath}`, + ); + const lines = content.split("\n").filter((line) => line.trim()); + if (lines.length === 0) { + throw invalidSession(filePath, "missing session header"); + } + + const header = parseHeaderLine(lines[0], filePath); + const entries: SessionTreeEntry[] = []; + let leafId: string | null = null; + for (let i = 1; i < lines.length; i++) { + const entry = parseEntryLine(lines[i], filePath, i + 1); + entries.push(entry); + leafId = leafIdAfterEntry(entry); + } + return { header, entries, leafId }; +} + +export class JsonlSessionStorage implements SessionStorage { + private readonly fs: JsonlSessionStorageFileSystem; + private readonly filePath: string; + private readonly metadata: JsonlSessionMetadata; + private entries: SessionTreeEntry[]; + private byId: Map; + private labelsById: Map; + private currentLeafId: string | null; + + private constructor( + fs: JsonlSessionStorageFileSystem, + filePath: string, + header: SessionHeader, + entries: SessionTreeEntry[], + leafId: string | null, + ) { + this.fs = fs; + this.filePath = filePath; + this.metadata = headerToSessionMetadata(header, this.filePath); + this.entries = entries; + this.byId = new Map(entries.map((entry) => [entry.id, entry])); + this.labelsById = buildLabelsById(entries); + this.currentLeafId = leafId; + } + + static async open( + fs: JsonlSessionStorageFileSystem, + filePath: string, + ): Promise { + const loaded = await loadJsonlStorage(fs, filePath); + return new JsonlSessionStorage(fs, filePath, loaded.header, loaded.entries, loaded.leafId); + } + + static async create( + fs: JsonlSessionStorageFileSystem, + filePath: string, + options: { + cwd: string; + sessionId: string; + parentSessionPath?: string; + }, + ): Promise { + const header: SessionHeader = { + type: "session", + version: 3, + id: options.sessionId, + timestamp: new Date().toISOString(), + cwd: options.cwd, + parentSession: options.parentSessionPath, + }; + getFileSystemResultOrThrow( + await fs.writeFile(filePath, `${JSON.stringify(header)}\n`), + `Failed to create session ${filePath}`, + ); + return new JsonlSessionStorage(fs, filePath, header, [], null); + } + + async getMetadata(): Promise { + return this.metadata; + } + + async getLeafId(): Promise { + if (this.currentLeafId !== null && !this.byId.has(this.currentLeafId)) { + throw new SessionError("invalid_session", `Entry ${this.currentLeafId} not found`); + } + return this.currentLeafId; + } + + async setLeafId(leafId: string | null): Promise { + if (leafId !== null && !this.byId.has(leafId)) { + throw new SessionError("not_found", `Entry ${leafId} not found`); + } + const entry: LeafEntry = { + type: "leaf", + id: generateEntryId(this.byId), + parentId: this.currentLeafId, + timestamp: new Date().toISOString(), + targetId: leafId, + }; + getFileSystemResultOrThrow( + await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`), + `Failed to append session leaf ${entry.id}`, + ); + this.entries.push(entry); + this.byId.set(entry.id, entry); + this.currentLeafId = leafId; + } + + async createEntryId(): Promise { + return generateEntryId(this.byId); + } + + async appendEntry(entry: SessionTreeEntry): Promise { + getFileSystemResultOrThrow( + await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`), + `Failed to append session entry ${entry.id}`, + ); + this.entries.push(entry); + this.byId.set(entry.id, entry); + updateLabelCache(this.labelsById, entry); + this.currentLeafId = leafIdAfterEntry(entry); + } + + async getEntry(id: string): Promise { + return this.byId.get(id); + } + + async findEntries( + type: TType, + ): Promise>> { + return this.entries.filter( + (entry): entry is Extract => entry.type === type, + ); + } + + async getLabel(id: string): Promise { + return this.labelsById.get(id); + } + + async getPathToRoot(leafId: string | null): Promise { + if (leafId === null) { + return []; + } + const path: SessionTreeEntry[] = []; + let current = this.byId.get(leafId); + if (!current) { + throw new SessionError("not_found", `Entry ${leafId} not found`); + } + while (current) { + path.unshift(current); + if (!current.parentId) { + break; + } + const parent = this.byId.get(current.parentId); + if (!parent) { + throw new SessionError("invalid_session", `Entry ${current.parentId} not found`); + } + current = parent; + } + return path; + } + + async getEntries(): Promise { + return [...this.entries]; + } +} diff --git a/packages/agent-core/src/harness/session/memory-repo.ts b/packages/agent-core/src/harness/session/memory-repo.ts new file mode 100644 index 00000000000..6bc7daefbce --- /dev/null +++ b/packages/agent-core/src/harness/session/memory-repo.ts @@ -0,0 +1,50 @@ +import { type Session, SessionError, type SessionMetadata, type SessionRepo } from "../types.js"; +import { InMemorySessionStorage } from "./memory-storage.js"; +import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./repo-utils.js"; + +export class InMemorySessionRepo implements SessionRepo { + private sessions = new Map(); + + async create(options: { id?: string } = {}): Promise { + const metadata: SessionMetadata = { + id: options.id ?? createSessionId(), + createdAt: createTimestamp(), + }; + const storage = new InMemorySessionStorage({ metadata }); + const session = toSession(storage); + this.sessions.set(metadata.id, session); + return session; + } + + async open(metadata: SessionMetadata): Promise { + const session = this.sessions.get(metadata.id); + if (!session) { + throw new SessionError("not_found", `Session not found: ${metadata.id}`); + } + return session; + } + + async list(): Promise { + return Promise.all([...this.sessions.values()].map((session) => session.getMetadata())); + } + + async delete(metadata: SessionMetadata): Promise { + this.sessions.delete(metadata.id); + } + + async fork( + sourceMetadata: SessionMetadata, + options: { entryId?: string; position?: "before" | "at"; id?: string }, + ): Promise { + const source = await this.open(sourceMetadata); + const forkedEntries = await getEntriesToFork(source.getStorage(), options); + const metadata: SessionMetadata = { + id: options.id ?? createSessionId(), + createdAt: createTimestamp(), + }; + const storage = new InMemorySessionStorage({ metadata, entries: forkedEntries }); + const session = toSession(storage); + this.sessions.set(metadata.id, session); + return session; + } +} diff --git a/packages/agent-core/src/harness/session/memory-storage.ts b/packages/agent-core/src/harness/session/memory-storage.ts new file mode 100644 index 00000000000..ab197c0a350 --- /dev/null +++ b/packages/agent-core/src/harness/session/memory-storage.ts @@ -0,0 +1,148 @@ +import { + type LeafEntry, + SessionError, + type SessionMetadata, + type SessionStorage, + type SessionTreeEntry, +} from "../types.js"; +import { uuidv7 } from "./uuid.js"; + +function updateLabelCache(labelsById: Map, entry: SessionTreeEntry): void { + if (entry.type !== "label") { + return; + } + const label = entry.label?.trim(); + if (label) { + labelsById.set(entry.targetId, label); + } else { + labelsById.delete(entry.targetId); + } +} + +function buildLabelsById(entries: SessionTreeEntry[]): Map { + const labelsById = new Map(); + for (const entry of entries) { + updateLabelCache(labelsById, entry); + } + return labelsById; +} + +function generateEntryId(byId: { has(id: string): boolean }): string { + for (let i = 0; i < 100; i++) { + const id = uuidv7().slice(0, 8); + if (!byId.has(id)) { + return id; + } + } + return uuidv7(); +} + +function leafIdAfterEntry(entry: SessionTreeEntry): string | null { + return entry.type === "leaf" ? entry.targetId : entry.id; +} + +export class InMemorySessionStorage< + TMetadata extends SessionMetadata = SessionMetadata, +> implements SessionStorage { + private readonly metadata: TMetadata; + private entries: SessionTreeEntry[]; + private byId: Map; + private labelsById: Map; + private leafId: string | null; + + constructor(options?: { entries?: SessionTreeEntry[]; metadata?: TMetadata }) { + this.entries = options?.entries ? [...options.entries] : []; + this.byId = new Map(this.entries.map((entry) => [entry.id, entry])); + this.labelsById = buildLabelsById(this.entries); + this.leafId = null; + for (const entry of this.entries) { + this.leafId = leafIdAfterEntry(entry); + } + if (this.leafId !== null && !this.byId.has(this.leafId)) { + throw new SessionError("invalid_session", `Entry ${this.leafId} not found`); + } + this.metadata = + options?.metadata ?? ({ id: uuidv7(), createdAt: new Date().toISOString() } as TMetadata); + } + + async getMetadata(): Promise { + return this.metadata; + } + + async getLeafId(): Promise { + if (this.leafId !== null && !this.byId.has(this.leafId)) { + throw new SessionError("invalid_session", `Entry ${this.leafId} not found`); + } + return this.leafId; + } + + async setLeafId(leafId: string | null): Promise { + if (leafId !== null && !this.byId.has(leafId)) { + throw new SessionError("not_found", `Entry ${leafId} not found`); + } + const entry: LeafEntry = { + type: "leaf", + id: generateEntryId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + targetId: leafId, + }; + this.entries.push(entry); + this.byId.set(entry.id, entry); + this.leafId = leafId; + } + + async createEntryId(): Promise { + return generateEntryId(this.byId); + } + + async appendEntry(entry: SessionTreeEntry): Promise { + this.entries.push(entry); + this.byId.set(entry.id, entry); + updateLabelCache(this.labelsById, entry); + this.leafId = leafIdAfterEntry(entry); + } + + async getEntry(id: string): Promise { + return this.byId.get(id); + } + + async findEntries( + type: TType, + ): Promise>> { + return this.entries.filter( + (entry): entry is Extract => entry.type === type, + ); + } + + async getLabel(id: string): Promise { + return this.labelsById.get(id); + } + + async getPathToRoot(leafId: string | null): Promise { + if (leafId === null) { + return []; + } + const path: SessionTreeEntry[] = []; + let current = this.byId.get(leafId); + if (!current) { + throw new SessionError("not_found", `Entry ${leafId} not found`); + } + while (current) { + path.unshift(current); + if (!current.parentId) { + break; + } + const parent = this.byId.get(current.parentId); + if (!parent) { + throw new SessionError("invalid_session", `Entry ${current.parentId} not found`); + } + current = parent; + } + return path; + } + + async getEntries(): Promise { + return [...this.entries]; + } +} diff --git a/packages/agent-core/src/harness/session/repo-utils.ts b/packages/agent-core/src/harness/session/repo-utils.ts new file mode 100644 index 00000000000..35f8162316d --- /dev/null +++ b/packages/agent-core/src/harness/session/repo-utils.ts @@ -0,0 +1,61 @@ +import { + type FileError, + type Result, + SessionError, + type SessionMetadata, + type SessionStorage, + type SessionTreeEntry, +} from "../types.js"; +import { Session } from "./session.js"; +import { uuidv7 } from "./uuid.js"; + +export function createSessionId(): string { + return uuidv7(); +} + +export function createTimestamp(): string { + return new Date().toISOString(); +} + +export function toSession( + storage: SessionStorage, +): Session { + return new Session(storage); +} + +export function getFileSystemResultOrThrow( + result: Result, + message: string, +): TValue { + if (!result.ok) { + const code = result.error.code === "not_found" ? "not_found" : "storage"; + throw new SessionError(code, `${message}: ${result.error.message}`, result.error); + } + return result.value; +} + +export async function getEntriesToFork( + storage: SessionStorage, + options: { entryId?: string; position?: "before" | "at" }, +): Promise { + if (!options.entryId) { + return storage.getEntries(); + } + const target = await storage.getEntry(options.entryId); + if (!target) { + throw new SessionError("invalid_fork_target", `Entry ${options.entryId} not found`); + } + let effectiveLeafId: string | null; + if ((options.position ?? "before") === "at") { + effectiveLeafId = target.id; + } else { + if (target.type !== "message" || target.message.role !== "user") { + throw new SessionError( + "invalid_fork_target", + `Entry ${options.entryId} is not a user message`, + ); + } + effectiveLeafId = target.parentId; + } + return storage.getPathToRoot(effectiveLeafId); +} diff --git a/packages/agent-core/src/harness/session/session.ts b/packages/agent-core/src/harness/session/session.ts new file mode 100644 index 00000000000..d2f64315139 --- /dev/null +++ b/packages/agent-core/src/harness/session/session.ts @@ -0,0 +1,270 @@ +import type { ImageContent, TextContent } from "openclaw/plugin-sdk/llm"; +import type { AgentMessage } from "../../types.js"; +import { + createBranchSummaryMessage, + createCompactionSummaryMessage, + createCustomMessage, +} from "../messages.js"; +import type { + BranchSummaryEntry, + CompactionEntry, + CustomEntry, + CustomMessageEntry, + LabelEntry, + MessageEntry, + ModelChangeEntry, + SessionContext, + SessionInfoEntry, + SessionMetadata, + SessionStorage, + SessionTreeEntry, + ThinkingLevelChangeEntry, +} from "../types.js"; +import { SessionError } from "../types.js"; + +export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext { + let thinkingLevel = "off"; + let model: { provider: string; modelId: string } | null = null; + let compaction: CompactionEntry | null = null; + + for (const entry of pathEntries) { + if (entry.type === "thinking_level_change") { + thinkingLevel = entry.thinkingLevel; + } else if (entry.type === "model_change") { + model = { provider: entry.provider, modelId: entry.modelId }; + } else if (entry.type === "message" && entry.message.role === "assistant") { + model = { provider: entry.message.provider, modelId: entry.message.model }; + } else if (entry.type === "compaction") { + compaction = entry; + } + } + + const messages: AgentMessage[] = []; + const appendMessage = (entry: SessionTreeEntry) => { + if (entry.type === "message") { + messages.push(entry.message); + } else if (entry.type === "custom_message") { + messages.push( + createCustomMessage( + entry.customType, + entry.content, + entry.display, + entry.details, + entry.timestamp, + ), + ); + } else if (entry.type === "branch_summary" && entry.summary) { + messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)); + } + }; + + if (compaction) { + messages.push( + createCompactionSummaryMessage( + compaction.summary, + compaction.tokensBefore, + compaction.timestamp, + ), + ); + const compactionIdx = pathEntries.findIndex( + (e) => e.type === "compaction" && e.id === compaction.id, + ); + let foundFirstKept = false; + for (let i = 0; i < compactionIdx; i++) { + const entry = pathEntries[i]; + if (entry.id === compaction.firstKeptEntryId) { + foundFirstKept = true; + } + if (foundFirstKept) { + appendMessage(entry); + } + } + for (let i = compactionIdx + 1; i < pathEntries.length; i++) { + appendMessage(pathEntries[i]); + } + } else { + for (const entry of pathEntries) { + appendMessage(entry); + } + } + + return { messages, thinkingLevel, model }; +} + +export class Session { + private storage: SessionStorage; + + constructor(storage: SessionStorage) { + this.storage = storage; + } + + getMetadata(): Promise { + return this.storage.getMetadata(); + } + + getStorage(): SessionStorage { + return this.storage; + } + + getLeafId(): Promise { + return this.storage.getLeafId(); + } + + getEntry(id: string): Promise { + return this.storage.getEntry(id); + } + + getEntries(): Promise { + return this.storage.getEntries(); + } + + async getBranch(fromId?: string): Promise { + const leafId = fromId ?? (await this.storage.getLeafId()); + return this.storage.getPathToRoot(leafId); + } + + async buildContext(): Promise { + return buildSessionContext(await this.getBranch()); + } + + getLabel(id: string): Promise { + return this.storage.getLabel(id); + } + + async getSessionName(): Promise { + const entries = await this.storage.findEntries("session_info"); + return entries[entries.length - 1]?.name?.trim() || undefined; + } + + private async appendTypedEntry(entry: SessionTreeEntry): Promise { + await this.storage.appendEntry(entry); + return entry.id; + } + + async appendMessage(message: AgentMessage): Promise { + return this.appendTypedEntry({ + type: "message", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + message, + } satisfies MessageEntry); + } + + async appendThinkingLevelChange(thinkingLevel: string): Promise { + return this.appendTypedEntry({ + type: "thinking_level_change", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + thinkingLevel, + } satisfies ThinkingLevelChangeEntry); + } + + async appendModelChange(provider: string, modelId: string): Promise { + return this.appendTypedEntry({ + type: "model_change", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + provider, + modelId, + } satisfies ModelChangeEntry); + } + + async appendCompaction( + summary: string, + firstKeptEntryId: string, + tokensBefore: number, + details?: unknown, + fromHook?: boolean, + ): Promise { + return this.appendTypedEntry({ + type: "compaction", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + summary, + firstKeptEntryId, + tokensBefore, + details, + fromHook, + } satisfies CompactionEntry); + } + + async appendCustomEntry(customType: string, data?: unknown): Promise { + return this.appendTypedEntry({ + type: "custom", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + customType, + data, + } satisfies CustomEntry); + } + + async appendCustomMessageEntry( + customType: string, + content: string | (TextContent | ImageContent)[], + display: boolean, + details?: unknown, + ): Promise { + return this.appendTypedEntry({ + type: "custom_message", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + customType, + content, + display, + details, + } satisfies CustomMessageEntry); + } + + async appendLabel(targetId: string, label: string | undefined): Promise { + if (!(await this.storage.getEntry(targetId))) { + throw new SessionError("not_found", `Entry ${targetId} not found`); + } + return this.appendTypedEntry({ + type: "label", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + targetId, + label, + } satisfies LabelEntry); + } + + async appendSessionName(name: string): Promise { + return this.appendTypedEntry({ + type: "session_info", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + name: name.trim(), + } satisfies SessionInfoEntry); + } + + async moveTo( + entryId: string | null, + summary?: { summary: string; details?: unknown; fromHook?: boolean }, + ): Promise { + if (entryId !== null && !(await this.storage.getEntry(entryId))) { + throw new SessionError("not_found", `Entry ${entryId} not found`); + } + await this.storage.setLeafId(entryId); + if (!summary) { + return undefined; + } + return this.appendTypedEntry({ + type: "branch_summary", + id: await this.storage.createEntryId(), + parentId: entryId, + timestamp: new Date().toISOString(), + fromId: entryId ?? "root", + summary: summary.summary, + details: summary.details, + fromHook: summary.fromHook, + } satisfies BranchSummaryEntry); + } +} diff --git a/packages/agent-core/src/harness/session/uuid.ts b/packages/agent-core/src/harness/session/uuid.ts new file mode 100644 index 00000000000..02b9a303cc1 --- /dev/null +++ b/packages/agent-core/src/harness/session/uuid.ts @@ -0,0 +1,54 @@ +let lastTimestamp = -Infinity; +let sequence = 0; + +function fillRandomBytes(bytes: Uint8Array): void { + const crypto = globalThis.crypto; + if (crypto?.getRandomValues) { + crypto.getRandomValues(bytes as Uint8Array); + return; + } + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Math.floor(Math.random() * 256); + } +} + +export function uuidv7(): string { + const random = new Uint8Array(16); + fillRandomBytes(random); + const timestamp = Date.now(); + + if (timestamp > lastTimestamp) { + sequence = random[6] * 0x1000000 + random[7] * 0x10000 + random[8] * 0x100 + random[9]; + lastTimestamp = timestamp; + } else { + sequence = (sequence + 1) >>> 0; + if (sequence === 0) { + lastTimestamp++; + } + } + + const bytes = new Uint8Array(16); + bytes[0] = (lastTimestamp / 0x10000000000) & 0xff; + bytes[1] = (lastTimestamp / 0x100000000) & 0xff; + bytes[2] = (lastTimestamp / 0x1000000) & 0xff; + bytes[3] = (lastTimestamp / 0x10000) & 0xff; + bytes[4] = (lastTimestamp / 0x100) & 0xff; + bytes[5] = lastTimestamp & 0xff; + bytes[6] = 0x70 | ((sequence >>> 28) & 0x0f); + bytes[7] = (sequence >>> 20) & 0xff; + bytes[8] = 0x80 | ((sequence >>> 14) & 0x3f); + bytes[9] = (sequence >>> 6) & 0xff; + bytes[10] = ((sequence & 0x3f) << 2) | (random[10] & 0x03); + bytes[11] = random[11]; + bytes[12] = random[12]; + bytes[13] = random[13]; + bytes[14] = random[14]; + bytes[15] = random[15]; + + return formatUuid(bytes); +} + +function formatUuid(bytes: Uint8Array): string { + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); + return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`; +} diff --git a/packages/agent-core/src/harness/skills.ts b/packages/agent-core/src/harness/skills.ts new file mode 100644 index 00000000000..cb7f9819e57 --- /dev/null +++ b/packages/agent-core/src/harness/skills.ts @@ -0,0 +1,463 @@ +import ignore from "ignore"; +import { parse } from "yaml"; +import { type ExecutionEnv, type FileInfo, type Result, type Skill, toError } from "./types.js"; + +const MAX_NAME_LENGTH = 64; +const MAX_DESCRIPTION_LENGTH = 1024; +const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"]; + +type IgnoreMatcher = ReturnType; + +export type SkillDiagnosticCode = + | "file_info_failed" + | "list_failed" + | "read_failed" + | "parse_failed" + | "invalid_metadata"; + +/** Warning produced while loading skills. */ +export interface SkillDiagnostic { + /** Diagnostic severity. Currently only warnings are emitted. */ + type: "warning"; + /** Stable diagnostic code. */ + code: SkillDiagnosticCode; + /** Human-readable diagnostic message. */ + message: string; + /** Path associated with the diagnostic. */ + path: string; +} + +interface SkillFrontmatter { + name?: string; + description?: string; + "disable-model-invocation"?: boolean; + [key: string]: unknown; +} + +/** Format a skill invocation prompt, optionally appending additional user instructions. */ +export function formatSkillInvocation(skill: Skill, additionalInstructions?: string): string { + const skillBlock = `\nReferences are relative to ${dirnameEnvPath(skill.filePath)}.\n\n${skill.content}\n`; + return additionalInstructions ? `${skillBlock}\n\n${additionalInstructions}` : skillBlock; +} + +/** + * Load skills from one or more directories. + * + * Traverses directories recursively, loads `SKILL.md` files, loads direct root `.md` files as skills, honors ignore files, + * and returns diagnostics for invalid skill files. Missing input directories are skipped. + */ +export async function loadSkills( + env: ExecutionEnv, + dirs: string | string[], +): Promise<{ skills: Skill[]; diagnostics: SkillDiagnostic[] }> { + const skills: Skill[] = []; + const diagnostics: SkillDiagnostic[] = []; + for (const dir of Array.isArray(dirs) ? dirs : [dirs]) { + const rootInfoResult = await env.fileInfo(dir); + if (!rootInfoResult.ok) { + if (rootInfoResult.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: rootInfoResult.error.message, + path: dir, + }); + } + continue; + } + const rootInfo = rootInfoResult.value; + if ((await resolveKind(env, rootInfo, diagnostics)) !== "directory") { + continue; + } + const result = await loadSkillsFromDirInternal( + env, + rootInfo.path, + true, + ignore(), + rootInfo.path, + ); + skills.push(...result.skills); + diagnostics.push(...result.diagnostics); + } + return { skills, diagnostics }; +} + +/** + * Load skills from source-tagged directories. + * + * Source values are preserved exactly and attached to every loaded skill and diagnostic. The agent package does not + * interpret source values; applications define their own provenance shape. + */ +export async function loadSourcedSkills( + env: ExecutionEnv, + inputs: Array<{ path: string; source: TSource }>, + mapSkill?: (skill: Skill, source: TSource) => TSkill, +): Promise<{ + skills: Array<{ skill: TSkill; source: TSource }>; + diagnostics: Array; +}> { + const skills: Array<{ skill: TSkill; source: TSource }> = []; + const diagnostics: Array = []; + for (const input of inputs) { + const result = await loadSkills(env, input.path); + for (const skill of result.skills) { + skills.push({ + skill: mapSkill ? mapSkill(skill, input.source) : (skill as TSkill), + source: input.source, + }); + } + for (const diagnostic of result.diagnostics) { + diagnostics.push({ ...diagnostic, source: input.source }); + } + } + return { skills, diagnostics }; +} + +async function loadSkillsFromDirInternal( + env: ExecutionEnv, + dir: string, + includeRootFiles: boolean, + ignoreMatcher: IgnoreMatcher, + rootDir: string, +): Promise<{ skills: Skill[]; diagnostics: SkillDiagnostic[] }> { + const skills: Skill[] = []; + const diagnostics: SkillDiagnostic[] = []; + + const dirInfoResult = await env.fileInfo(dir); + if (!dirInfoResult.ok) { + if (dirInfoResult.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: dirInfoResult.error.message, + path: dir, + }); + } + return { skills, diagnostics }; + } + const dirInfo = dirInfoResult.value; + if ((await resolveKind(env, dirInfo, diagnostics)) !== "directory") { + return { skills, diagnostics }; + } + + await addIgnoreRules(env, ignoreMatcher, dir, rootDir, diagnostics); + + const entriesResult = await env.listDir(dir); + if (!entriesResult.ok) { + diagnostics.push({ + type: "warning", + code: "list_failed", + message: entriesResult.error.message, + path: dir, + }); + return { skills, diagnostics }; + } + const entries = entriesResult.value; + + for (const entry of entries) { + if (entry.name !== "SKILL.md") { + continue; + } + const fullPath = entry.path; + const kind = await resolveKind(env, entry, diagnostics); + if (kind !== "file") { + continue; + } + const relPath = relativeEnvPath(rootDir, fullPath); + if (ignoreMatcher.ignores(relPath)) { + continue; + } + + const result = await loadSkillFromFile(env, fullPath); + if (result.skill) { + skills.push(result.skill); + } + diagnostics.push(...result.diagnostics); + return { skills, diagnostics }; + } + + for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) { + if (entry.name.startsWith(".") || entry.name === "node_modules") { + continue; + } + const fullPath = entry.path; + const kind = await resolveKind(env, entry, diagnostics); + if (!kind) { + continue; + } + + const relPath = relativeEnvPath(rootDir, fullPath); + const ignorePath = kind === "directory" ? `${relPath}/` : relPath; + if (ignoreMatcher.ignores(ignorePath)) { + continue; + } + + if (kind === "directory") { + const result = await loadSkillsFromDirInternal(env, fullPath, false, ignoreMatcher, rootDir); + skills.push(...result.skills); + diagnostics.push(...result.diagnostics); + continue; + } + + if (kind !== "file" || !includeRootFiles || !entry.name.endsWith(".md")) { + continue; + } + const result = await loadSkillFromFile(env, fullPath); + if (result.skill) { + skills.push(result.skill); + } + diagnostics.push(...result.diagnostics); + } + + return { skills, diagnostics }; +} + +async function addIgnoreRules( + env: ExecutionEnv, + ig: IgnoreMatcher, + dir: string, + rootDir: string, + diagnostics: SkillDiagnostic[], +): Promise { + const relativeDir = relativeEnvPath(rootDir, dir); + const prefix = relativeDir ? `${relativeDir}/` : ""; + + for (const filename of IGNORE_FILE_NAMES) { + const ignorePath = joinEnvPath(dir, filename); + const info = await env.fileInfo(ignorePath); + if (!info.ok) { + if (info.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: info.error.message, + path: ignorePath, + }); + } + continue; + } + if (info.value.kind !== "file") { + continue; + } + const content = await env.readTextFile(ignorePath); + if (!content.ok) { + diagnostics.push({ + type: "warning", + code: "read_failed", + message: content.error.message, + path: ignorePath, + }); + continue; + } + const patterns = content.value + .split(/\r?\n/) + .map((line) => prefixIgnorePattern(line, prefix)) + .filter((line): line is string => Boolean(line)); + if (patterns.length > 0) { + ig.add(patterns); + } + } +} + +function prefixIgnorePattern(line: string, prefix: string): string | null { + const trimmed = line.trim(); + if (!trimmed) { + return null; + } + if (trimmed.startsWith("#") && !trimmed.startsWith("\\#")) { + return null; + } + + let pattern = line; + let negated = false; + if (pattern.startsWith("!")) { + negated = true; + pattern = pattern.slice(1); + } else if (pattern.startsWith("\\!")) { + pattern = pattern.slice(1); + } + if (pattern.startsWith("/")) { + pattern = pattern.slice(1); + } + const prefixed = prefix ? `${prefix}${pattern}` : pattern; + return negated ? `!${prefixed}` : prefixed; +} + +async function loadSkillFromFile( + env: ExecutionEnv, + filePath: string, +): Promise<{ skill: Skill | null; diagnostics: SkillDiagnostic[] }> { + const diagnostics: SkillDiagnostic[] = []; + const rawContent = await env.readTextFile(filePath); + if (!rawContent.ok) { + diagnostics.push({ + type: "warning", + code: "read_failed", + message: rawContent.error.message, + path: filePath, + }); + return { skill: null, diagnostics }; + } + + const parsed = parseFrontmatter(rawContent.value) as Result< + { frontmatter: SkillFrontmatter; body: string }, + Error + >; + if (!parsed.ok) { + diagnostics.push({ + type: "warning", + code: "parse_failed", + message: parsed.error.message, + path: filePath, + }); + return { skill: null, diagnostics }; + } + + const { frontmatter, body } = parsed.value; + const skillDir = dirnameEnvPath(filePath); + const parentDirName = basenameEnvPath(skillDir); + const description = + typeof frontmatter.description === "string" ? frontmatter.description : undefined; + + for (const error of validateDescription(description)) { + diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath }); + } + + const frontmatterName = typeof frontmatter.name === "string" ? frontmatter.name : undefined; + const name = frontmatterName || parentDirName; + for (const error of validateName(name, parentDirName)) { + diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath }); + } + + if (!description || description.trim() === "") { + return { skill: null, diagnostics }; + } + + return { + skill: { + name, + description, + content: body, + filePath, + disableModelInvocation: frontmatter["disable-model-invocation"] === true, + }, + diagnostics, + }; +} + +function validateName(name: string, parentDirName: string): string[] { + const errors: string[] = []; + if (name !== parentDirName) { + errors.push(`name "${name}" does not match parent directory "${parentDirName}"`); + } + if (name.length > MAX_NAME_LENGTH) { + errors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`); + } + if (!/^[a-z0-9-]+$/.test(name)) { + errors.push("name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)"); + } + if (name.startsWith("-") || name.endsWith("-")) { + errors.push("name must not start or end with a hyphen"); + } + if (name.includes("--")) { + errors.push("name must not contain consecutive hyphens"); + } + return errors; +} + +function validateDescription(description: string | undefined): string[] { + const errors: string[] = []; + if (!description || description.trim() === "") { + errors.push("description is required"); + } else if (description.length > MAX_DESCRIPTION_LENGTH) { + errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`); + } + return errors; +} + +function parseFrontmatter( + content: string, +): Result<{ frontmatter: Record; body: string }, Error> { + try { + const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (!normalized.startsWith("---")) { + return { ok: true, value: { frontmatter: {}, body: normalized } }; + } + const endIndex = normalized.indexOf("\n---", 3); + if (endIndex === -1) { + return { ok: true, value: { frontmatter: {}, body: normalized } }; + } + const yamlString = normalized.slice(4, endIndex); + const body = normalized.slice(endIndex + 4).trim(); + return { + ok: true, + value: { frontmatter: (parse(yamlString) ?? {}) as Record, body }, + }; + } catch (error) { + return { ok: false, error: toError(error) }; + } +} + +async function resolveKind( + env: ExecutionEnv, + info: FileInfo, + diagnostics: SkillDiagnostic[], +): Promise<"file" | "directory" | undefined> { + if (info.kind === "file" || info.kind === "directory") { + return info.kind; + } + const canonicalPath = await env.canonicalPath(info.path); + if (!canonicalPath.ok) { + if (canonicalPath.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: canonicalPath.error.message, + path: info.path, + }); + } + return undefined; + } + const target = await env.fileInfo(canonicalPath.value); + if (!target.ok) { + if (target.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: target.error.message, + path: info.path, + }); + } + return undefined; + } + return target.value.kind === "file" || target.value.kind === "directory" + ? target.value.kind + : undefined; +} + +function joinEnvPath(base: string, child: string): string { + return `${base.replace(/\/+$/, "")}/${child.replace(/^\/+/, "")}`; +} + +function dirnameEnvPath(path: string): string { + const normalized = path.replace(/\/+$/, ""); + const slashIndex = normalized.lastIndexOf("/"); + return slashIndex <= 0 ? "/" : normalized.slice(0, slashIndex); +} + +function basenameEnvPath(path: string): string { + const normalized = path.replace(/\/+$/, ""); + const slashIndex = normalized.lastIndexOf("/"); + return slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1); +} + +function relativeEnvPath(root: string, path: string): string { + const normalizedRoot = root.replace(/\/+$/, ""); + const normalizedPath = path.replace(/\/+$/, ""); + if (normalizedPath === normalizedRoot) { + return ""; + } + return normalizedPath.startsWith(`${normalizedRoot}/`) + ? normalizedPath.slice(normalizedRoot.length + 1) + : normalizedPath.replace(/^\/+/, ""); +} diff --git a/packages/agent-core/src/harness/system-prompt.ts b/packages/agent-core/src/harness/system-prompt.ts new file mode 100644 index 00000000000..51327d510bb --- /dev/null +++ b/packages/agent-core/src/harness/system-prompt.ts @@ -0,0 +1,36 @@ +import type { Skill } from "./types.js"; + +export function formatSkillsForSystemPrompt(skills: Skill[]): string { + const visibleSkills = skills.filter((skill) => !skill.disableModelInvocation); + if (visibleSkills.length === 0) { + return ""; + } + + const lines = [ + "The following skills provide specialized instructions for specific tasks.", + "Read the full skill file when the task matches its description.", + "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.", + "", + "", + ]; + + for (const skill of visibleSkills) { + lines.push(" "); + lines.push(` ${escapeXml(skill.name)}`); + lines.push(` ${escapeXml(skill.description)}`); + lines.push(` ${escapeXml(skill.filePath)}`); + lines.push(" "); + } + + lines.push(""); + return lines.join("\n"); +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/packages/agent-core/src/harness/types.ts b/packages/agent-core/src/harness/types.ts new file mode 100644 index 00000000000..b53fca61cf1 --- /dev/null +++ b/packages/agent-core/src/harness/types.ts @@ -0,0 +1,850 @@ +import type { + ImageContent, + Model, + SimpleStreamOptions, + TextContent, + Transport, +} from "openclaw/plugin-sdk/llm"; +import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../index.js"; +import type { Session } from "./session/session.js"; + +/** Result of a fallible operation. Expected failures are returned as `ok: false` instead of thrown. */ +export type Result = { ok: true; value: TValue } | { ok: false; error: TError }; + +/** Create a successful {@link Result}. */ +export function ok(value: TValue): Result { + return { ok: true, value }; +} + +/** Create a failed {@link Result}. */ +export function err(error: TError): Result { + return { ok: false, error }; +} + +/** Return the success value or throw the failure error. Intended for tests and explicit adapter boundaries. */ +export function getOrThrow(result: Result): TValue { + if (!result.ok) { + throw result.error; + } + return result.value; +} + +/** Return the success value or `undefined`. Only object values are allowed to avoid truthiness bugs with primitives. */ +export function getOrUndefined( + result: Result, +): TValue | undefined { + return result.ok ? result.value : undefined; +} + +/** Normalize unknown thrown values into Error instances before using them as typed error causes. */ +export function toError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + if (typeof error === "string") { + return new Error(error); + } + try { + return new Error(JSON.stringify(error)); + } catch { + return new Error(String(error)); + } +} + +/** + * Skill loaded from a `SKILL.md` file or provided by an application. + * + * `name`, `description`, and `filePath` are inserted into the system prompt in an XML-formatted block as suggested by agentskills.io. + * Use {@link formatSkillsForSystemPrompt} to generate the spec-compatible system prompt block. + */ +export interface Skill { + /** Stable skill name used for lookup and model-visible listings. */ + name: string; + /** Short model-visible description of when to use the skill. */ + description: string; + /** Full skill instructions. */ + content: string; + /** Absolute path to the skill file. Used for model-visible location and resolving relative references. */ + filePath: string; + /** Exclude this skill from model-visible skill lists while still allowing explicit application invocation. */ + disableModelInvocation?: boolean; +} + +/** Prompt template that can be formatted into a prompt for explicit invocation. */ +export interface PromptTemplate { + /** Stable template name used for lookup or application command routing. */ + name: string; + /** Optional description for command lists or autocomplete. */ + description?: string; + /** Template content. Argument placeholders are formatted by `formatPromptTemplateInvocation`. */ + content: string; +} + +/** Resources made available to explicit invocation methods and system-prompt callbacks. */ +export interface AgentHarnessResources< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, +> { + /** Prompt templates available for explicit invocation. */ + promptTemplates?: TPromptTemplate[]; + /** Skills available to the model and explicit skill invocation. */ + skills?: TSkill[]; +} + +/** Curated provider request options owned by the harness and snapshotted per turn. */ +export interface AgentHarnessStreamOptions { + /** Preferred transport forwarded to the stream function. */ + transport?: Transport; + /** Provider request timeout in milliseconds. */ + timeoutMs?: number; + /** Maximum provider retry attempts. */ + maxRetries?: number; + /** Optional cap for provider-requested retry delays. */ + maxRetryDelayMs?: number; + /** Additional request headers merged with auth and lifecycle headers. */ + headers?: Record; + /** Provider metadata forwarded with requests. */ + metadata?: SimpleStreamOptions["metadata"]; + /** Provider cache retention hint. */ + cacheRetention?: SimpleStreamOptions["cacheRetention"]; +} + +/** Per-request stream option patch returned by provider hooks. */ +export interface AgentHarnessStreamOptionsPatch extends Omit< + Partial, + "headers" | "metadata" +> { + /** Header patch. `undefined` values delete keys; explicit `headers: undefined` clears all headers. */ + headers?: Record; + /** Metadata patch. `undefined` values delete keys; explicit `metadata: undefined` clears all metadata. */ + metadata?: Record; +} + +/** Kind of filesystem object as addressed by a {@link FileSystem}. Symlinks are not followed automatically. */ +export type FileKind = "file" | "directory" | "symlink"; + +/** Stable, backend-independent file error codes returned by {@link FileSystem} file operations. */ +export type FileErrorCode = + | "aborted" + | "not_found" + | "permission_denied" + | "not_directory" + | "is_directory" + | "invalid" + | "not_supported" + | "unknown"; + +/** Error returned by {@link FileSystem} file operations. */ +export class FileError extends Error { + /** Backend-independent error code. */ + public code: FileErrorCode; + /** Absolute addressed path associated with the failure, when available. */ + public path?: string; + + constructor(code: FileErrorCode, message: string, path?: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "FileError"; + this.code = code; + this.path = path; + } +} + +/** Stable, backend-independent execution error codes returned by {@link ExecutionEnv.exec}. */ +export type ExecutionErrorCode = + | "aborted" + | "timeout" + | "shell_unavailable" + | "spawn_error" + | "callback_error" + | "unknown"; + +/** Error returned by {@link ExecutionEnv.exec}. */ +export class ExecutionError extends Error { + /** Backend-independent error code. */ + public code: ExecutionErrorCode; + + constructor(code: ExecutionErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "ExecutionError"; + this.code = code; + } +} + +/** Stable compaction error codes returned by compaction helpers. */ +export type CompactionErrorCode = + | "aborted" + | "summarization_failed" + | "invalid_session" + | "unknown"; + +/** Error returned by compaction helpers. */ +export class CompactionError extends Error { + /** Backend-independent error code. */ + public code: CompactionErrorCode; + + constructor(code: CompactionErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "CompactionError"; + this.code = code; + } +} + +/** Stable branch-summary error codes returned by branch summarization helpers. */ +export type BranchSummaryErrorCode = "aborted" | "summarization_failed" | "invalid_session"; + +/** Error returned by branch summarization helpers. */ +export class BranchSummaryError extends Error { + /** Backend-independent error code. */ + public code: BranchSummaryErrorCode; + + constructor(code: BranchSummaryErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "BranchSummaryError"; + this.code = code; + } +} + +export type SessionErrorCode = + | "not_found" + | "invalid_session" + | "invalid_entry" + | "invalid_fork_target" + | "storage" + | "unknown"; + +/** Error thrown by session storage, repositories, and session tree operations. */ +export class SessionError extends Error { + /** Session subsystem error code. */ + public code: SessionErrorCode; + + constructor(code: SessionErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "SessionError"; + this.code = code; + } +} + +export type AgentHarnessErrorCode = + | "busy" + | "invalid_state" + | "invalid_argument" + | "session" + | "hook" + | "auth" + | "compaction" + | "branch_summary" + | "unknown"; + +/** Public AgentHarness failure with a stable top-level classification. */ +export class AgentHarnessError extends Error { + public code: AgentHarnessErrorCode; + + constructor(code: AgentHarnessErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "AgentHarnessError"; + this.code = code; + } +} + +/** Metadata for one filesystem object in a {@link FileSystem}. */ +export interface FileInfo { + /** Basename of {@link path}. */ + name: string; + /** Absolute, syntactically normalized addressed path in the execution environment. Symlinks are not followed. */ + path: string; + /** Object kind. Symlink targets are not followed; use {@link FileSystem.canonicalPath} explicitly. */ + kind: FileKind; + /** Size in bytes for the addressed filesystem object. */ + size: number; + /** Modification time as milliseconds since Unix epoch. */ + mtimeMs: number; +} + +/** Options for {@link Shell.exec}. */ +export interface ExecutionEnvExecOptions { + /** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. Defaults to {@link ExecutionEnv.cwd}. */ + cwd?: string; + /** Additional environment variables for the command. Values override the environment defaults. Defaults to no overrides. */ + env?: Record; + /** Timeout in seconds. Implementations should return a timeout error when the command exceeds this duration. Defaults to no timeout. */ + timeout?: number; + /** Abort signal used to terminate the command. Defaults to no abort signal. */ + abortSignal?: AbortSignal; + /** Called with stdout chunks as they are produced. */ + onStdout?: (chunk: string) => void; + /** Called with stderr chunks as they are produced. */ + onStderr?: (chunk: string) => void; +} + +/** + * Filesystem capability used by the harness. + * + * Paths passed to methods may be absolute or relative to {@link cwd}. Paths returned by file operations are addressed paths + * in the filesystem namespace, but are not canonicalized through symlinks unless returned by {@link canonicalPath}. + * + * Operation methods must never throw or reject. All filesystem failures, including unexpected backend failures, must be + * encoded in the returned {@link Result}. Implementations must preserve this invariant. + */ +export interface FileSystem { + /** Current working directory for relative paths. */ + cwd: string; + + /** Return an absolute addressed path without requiring it to exist and without resolving symlinks. */ + absolutePath(path: string, abortSignal?: AbortSignal): Promise>; + /** Join path segments in the filesystem namespace without requiring the result to exist. */ + joinPath(parts: string[], abortSignal?: AbortSignal): Promise>; + /** Read a UTF-8 text file. */ + readTextFile(path: string, abortSignal?: AbortSignal): Promise>; + /** Read UTF-8 text lines. Implementations should stop once `maxLines` lines have been read. */ + readTextLines( + path: string, + options?: { maxLines?: number; abortSignal?: AbortSignal }, + ): Promise>; + /** Read a binary file. */ + readBinaryFile(path: string, abortSignal?: AbortSignal): Promise>; + /** Create or overwrite a file, creating parent directories when supported. */ + writeFile( + path: string, + content: string | Uint8Array, + abortSignal?: AbortSignal, + ): Promise>; + /** Create or append to a file, creating parent directories when supported. */ + appendFile( + path: string, + content: string | Uint8Array, + abortSignal?: AbortSignal, + ): Promise>; + /** Return metadata for the addressed path without following symlinks. */ + fileInfo(path: string, abortSignal?: AbortSignal): Promise>; + /** List direct children of a directory without following symlinks. */ + listDir(path: string, abortSignal?: AbortSignal): Promise>; + /** Return the canonical path for an existing path, resolving symlinks where supported. */ + canonicalPath(path: string, abortSignal?: AbortSignal): Promise>; + /** Return false for missing paths. Other errors, such as permission failures, return a {@link FileError}. */ + exists(path: string, abortSignal?: AbortSignal): Promise>; + /** Create a directory. Defaults: `recursive: true`, no abort signal. */ + createDir( + path: string, + options?: { recursive?: boolean; abortSignal?: AbortSignal }, + ): Promise>; + /** Remove a file or directory. Defaults: `recursive: false`, `force: false`, no abort signal. */ + remove( + path: string, + options?: { recursive?: boolean; force?: boolean; abortSignal?: AbortSignal }, + ): Promise>; + /** Create a temporary directory and return its absolute path. Defaults: `prefix: "tmp-"`, no abort signal. */ + createTempDir(prefix?: string, abortSignal?: AbortSignal): Promise>; + /** Create a temporary file and return its absolute path. Defaults: `prefix: ""`, `suffix: ""`, no abort signal. */ + createTempFile(options?: { + prefix?: string; + suffix?: string; + abortSignal?: AbortSignal; + }): Promise>; + + /** Release filesystem resources. Must be best-effort and must not throw or reject. */ + cleanup(): Promise; +} + +/** Shell execution capability used by the harness. */ +export interface Shell { + /** Execute a shell command in {@link FileSystem.cwd} unless `options.cwd` is provided. */ + exec( + command: string, + options?: ExecutionEnvExecOptions, + ): Promise>; + /** Release shell resources. Must be best-effort and must not throw or reject. */ + cleanup(): Promise; +} + +/** Filesystem and process execution environment used by the harness. */ +export interface ExecutionEnv extends FileSystem, Shell {} + +export interface SessionTreeEntryBase { + type: string; + id: string; + parentId: string | null; + timestamp: string; +} + +export interface MessageEntry extends SessionTreeEntryBase { + type: "message"; + message: AgentMessage; +} + +export interface ThinkingLevelChangeEntry extends SessionTreeEntryBase { + type: "thinking_level_change"; + thinkingLevel: string; +} + +export interface ModelChangeEntry extends SessionTreeEntryBase { + type: "model_change"; + provider: string; + modelId: string; +} + +export interface CompactionEntry extends SessionTreeEntryBase { + type: "compaction"; + summary: string; + firstKeptEntryId: string; + tokensBefore: number; + details?: T; + fromHook?: boolean; +} + +export interface BranchSummaryEntry extends SessionTreeEntryBase { + type: "branch_summary"; + fromId: string; + summary: string; + details?: T; + fromHook?: boolean; +} + +export interface CustomEntry extends SessionTreeEntryBase { + type: "custom"; + customType: string; + data?: T; +} + +export interface CustomMessageEntry extends SessionTreeEntryBase { + type: "custom_message"; + customType: string; + content: string | (TextContent | ImageContent)[]; + details?: T; + display: boolean; +} + +export interface LabelEntry extends SessionTreeEntryBase { + type: "label"; + targetId: string; + label: string | undefined; +} + +export interface SessionInfoEntry extends SessionTreeEntryBase { + type: "session_info"; // legacy name, kept for backwards compatibility + name?: string; +} + +export interface LeafEntry extends SessionTreeEntryBase { + type: "leaf"; + targetId: string | null; +} + +export type SessionTreeEntry = + | MessageEntry + | ThinkingLevelChangeEntry + | ModelChangeEntry + | CompactionEntry + | BranchSummaryEntry + | CustomEntry + | CustomMessageEntry + | LabelEntry + | SessionInfoEntry + | LeafEntry; + +export interface SessionContext { + messages: AgentMessage[]; + thinkingLevel: string; + model: { provider: string; modelId: string } | null; +} + +export interface SessionMetadata { + id: string; + createdAt: string; +} + +export interface JsonlSessionMetadata extends SessionMetadata { + cwd: string; + path: string; + parentSessionPath?: string; +} + +export interface SessionStorage { + getMetadata(): Promise; + getLeafId(): Promise; + /** Persist a leaf entry that records the active session-tree leaf. */ + setLeafId(leafId: string | null): Promise; + createEntryId(): Promise; + appendEntry(entry: SessionTreeEntry): Promise; + getEntry(id: string): Promise; + findEntries( + type: TType, + ): Promise>>; + getLabel(id: string): Promise; + getPathToRoot(leafId: string | null): Promise; + getEntries(): Promise; +} + +export type { Session } from "./session/session.js"; + +export interface SessionCreateOptions { + id?: string; +} + +export interface SessionForkOptions { + entryId?: string; + position?: "before" | "at"; + id?: string; +} + +export interface SessionRepo< + TMetadata extends SessionMetadata = SessionMetadata, + TCreateOptions extends SessionCreateOptions = SessionCreateOptions, + TListOptions = void, +> { + create(options: TCreateOptions): Promise>; + open(metadata: TMetadata): Promise>; + list(options?: TListOptions): Promise; + delete(metadata: TMetadata): Promise; + fork( + source: TMetadata, + options: SessionForkOptions & TCreateOptions, + ): Promise>; +} + +export interface JsonlSessionCreateOptions extends SessionCreateOptions { + cwd: string; + parentSessionPath?: string; +} + +export interface JsonlSessionListOptions { + cwd?: string; +} + +export interface JsonlSessionRepoApi extends SessionRepo< + JsonlSessionMetadata, + JsonlSessionCreateOptions, + JsonlSessionListOptions +> {} + +export type AgentHarnessPhase = "idle" | "turn" | "compaction" | "branch_summary" | "retry"; + +export type PendingSessionWrite = SessionTreeEntry extends infer TEntry + ? TEntry extends SessionTreeEntry + ? Omit + : never + : never; + +export interface QueueUpdateEvent { + type: "queue_update"; + steer: AgentMessage[]; + followUp: AgentMessage[]; + nextTurn: AgentMessage[]; +} + +export interface SavePointEvent { + type: "save_point"; + hadPendingMutations: boolean; +} + +export interface AbortEvent { + type: "abort"; + clearedSteer: AgentMessage[]; + clearedFollowUp: AgentMessage[]; +} + +export interface SettledEvent { + type: "settled"; + nextTurnCount: number; +} + +export interface BeforeAgentStartEvent< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, +> { + type: "before_agent_start"; + prompt: string; + images?: ImageContent[]; + systemPrompt: string; + resources: AgentHarnessResources; +} + +export interface ContextEvent { + type: "context"; + messages: AgentMessage[]; +} + +export interface BeforeProviderRequestEvent { + type: "before_provider_request"; + model: Model; + sessionId: string; + streamOptions: AgentHarnessStreamOptions; +} + +export interface BeforeProviderPayloadEvent { + type: "before_provider_payload"; + model: Model; + payload: unknown; +} + +export interface AfterProviderResponseEvent { + type: "after_provider_response"; + status: number; + headers: Record; +} + +export interface ToolCallEvent { + type: "tool_call"; + toolCallId: string; + toolName: string; + input: Record; +} + +export interface ToolResultEvent { + type: "tool_result"; + toolCallId: string; + toolName: string; + input: Record; + content: Array; + details: unknown; + isError: boolean; +} + +export interface SessionBeforeCompactEvent { + type: "session_before_compact"; + preparation: CompactionPreparation; + branchEntries: SessionTreeEntry[]; + customInstructions?: string; + signal: AbortSignal; +} + +export interface SessionCompactEvent { + type: "session_compact"; + compactionEntry: CompactionEntry; + fromHook: boolean; +} + +export interface SessionBeforeTreeEvent { + type: "session_before_tree"; + preparation: TreePreparation; + signal: AbortSignal; +} + +export interface SessionTreeEvent { + type: "session_tree"; + newLeafId: string | null; + oldLeafId: string | null; + summaryEntry?: BranchSummaryEntry; + fromHook?: boolean; +} + +export interface ModelSelectEvent { + type: "model_select"; + model: Model; + previousModel: Model | undefined; + source: "set" | "restore"; +} + +export interface ThinkingLevelSelectEvent { + type: "thinking_level_select"; + level: ThinkingLevel; + previousLevel: ThinkingLevel; +} + +export interface ResourcesUpdateEvent< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, +> { + type: "resources_update"; + resources: AgentHarnessResources; + previousResources: AgentHarnessResources; +} + +export type AgentHarnessOwnEvent< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, +> = + | QueueUpdateEvent + | SavePointEvent + | AbortEvent + | SettledEvent + | BeforeAgentStartEvent + | ContextEvent + | BeforeProviderRequestEvent + | BeforeProviderPayloadEvent + | AfterProviderResponseEvent + | ToolCallEvent + | ToolResultEvent + | SessionBeforeCompactEvent + | SessionCompactEvent + | SessionBeforeTreeEvent + | SessionTreeEvent + | ModelSelectEvent + | ThinkingLevelSelectEvent + | ResourcesUpdateEvent; + +export type AgentHarnessEvent< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, +> = AgentEvent | AgentHarnessOwnEvent; + +export interface BeforeAgentStartResult { + messages?: AgentMessage[]; + systemPrompt?: string; +} + +export interface ContextResult { + messages: AgentMessage[]; +} + +export interface BeforeProviderRequestResult { + streamOptions?: AgentHarnessStreamOptionsPatch; +} + +export interface BeforeProviderPayloadResult { + payload: unknown; +} + +export interface ToolCallResult { + block?: boolean; + reason?: string; +} + +export interface ToolResultPatch { + content?: Array; + details?: unknown; + isError?: boolean; + terminate?: boolean; +} + +export interface SessionBeforeCompactResult { + cancel?: boolean; + compaction?: CompactResult; +} + +export interface SessionBeforeTreeResult { + cancel?: boolean; + summary?: { summary: string; details?: unknown }; + customInstructions?: string; + replaceInstructions?: boolean; + label?: string; +} + +export type AgentHarnessEventResultMap = { + before_agent_start: BeforeAgentStartResult | undefined; + context: ContextResult | undefined; + before_provider_request: BeforeProviderRequestResult | undefined; + before_provider_payload: BeforeProviderPayloadResult | undefined; + after_provider_response: undefined; + tool_call: ToolCallResult | undefined; + tool_result: ToolResultPatch | undefined; + session_before_compact: SessionBeforeCompactResult | undefined; + session_compact: undefined; + session_before_tree: SessionBeforeTreeResult | undefined; + session_tree: undefined; + model_select: undefined; + thinking_level_select: undefined; + resources_update: undefined; + queue_update: undefined; + save_point: undefined; + abort: undefined; + settled: undefined; +}; + +export interface AgentHarnessPromptOptions { + images?: ImageContent[]; +} + +export interface AbortResult { + clearedSteer: AgentMessage[]; + clearedFollowUp: AgentMessage[]; +} + +export interface CompactResult { + summary: string; + firstKeptEntryId: string; + tokensBefore: number; + details?: unknown; +} + +export interface NavigateTreeResult { + cancelled: boolean; + editorText?: string; + summaryEntry?: BranchSummaryEntry; +} + +export interface CompactionSettings { + enabled: boolean; + reserveTokens: number; + keepRecentTokens: number; +} + +export interface CompactionPreparation { + firstKeptEntryId: string; + messagesToSummarize: AgentMessage[]; + turnPrefixMessages: AgentMessage[]; + isSplitTurn: boolean; + tokensBefore: number; + previousSummary?: string; + fileOps: FileOperations; + settings: CompactionSettings; +} + +export interface FileOperations { + read: Set; + written: Set; + edited: Set; +} + +export interface TreePreparation { + targetId: string; + oldLeafId: string | null; + commonAncestorId: string | null; + entriesToSummarize: SessionTreeEntry[]; + userWantsSummary: boolean; + customInstructions?: string; + replaceInstructions?: boolean; + label?: string; +} + +export interface GenerateBranchSummaryOptions { + model: Model; + apiKey: string; + headers?: Record; + signal: AbortSignal; + customInstructions?: string; + replaceInstructions?: boolean; + reserveTokens?: number; +} + +export interface BranchSummaryResult { + summary: string; + readFiles: string[]; + modifiedFiles: string[]; +} + +export interface AgentHarnessOptions< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, + TTool extends AgentTool = AgentTool, +> { + env: ExecutionEnv; + session: Session; + tools?: TTool[]; + /** + * Concrete resources available to explicit invocation methods and system-prompt callbacks. + * Applications own loading/reloading resources and should call `setResources()` with new values. + */ + resources?: AgentHarnessResources; + systemPrompt?: + | string + | ((context: { + env: ExecutionEnv; + session: Session; + model: Model; + thinkingLevel: ThinkingLevel; + activeTools: TTool[]; + resources: AgentHarnessResources; + }) => string | Promise); + getApiKeyAndHeaders?: ( + model: Model, + ) => Promise<{ apiKey: string; headers?: Record } | undefined>; + /** Curated stream/provider request options. Snapshotted at turn start. */ + streamOptions?: AgentHarnessStreamOptions; + model: Model; + thinkingLevel?: ThinkingLevel; + activeToolNames?: string[]; + steeringMode?: QueueMode; + followUpMode?: QueueMode; +} + +export type { AgentHarness } from "./agent-harness.js"; diff --git a/packages/agent-core/src/harness/utils/shell-output.ts b/packages/agent-core/src/harness/utils/shell-output.ts new file mode 100644 index 00000000000..a72b7fffa2a --- /dev/null +++ b/packages/agent-core/src/harness/utils/shell-output.ts @@ -0,0 +1,174 @@ +import { + type ExecutionEnv, + type ExecutionEnvExecOptions, + ExecutionError, + err, + ok, + type Result, + toError, +} from "../types.js"; +import { DEFAULT_MAX_BYTES, truncateTail } from "./truncate.js"; + +export interface ShellCaptureOptions extends Omit< + ExecutionEnvExecOptions, + "onStdout" | "onStderr" +> { + onChunk?: (chunk: string) => void; +} + +export interface ShellCaptureResult { + output: string; + exitCode: number | undefined; + cancelled: boolean; + truncated: boolean; + fullOutputPath?: string; +} + +function toExecutionError(error: unknown): ExecutionError { + if (error instanceof ExecutionError) { + return error; + } + const cause = toError(error); + return new ExecutionError("unknown", cause.message, cause); +} + +export function sanitizeBinaryOutput(str: string): string { + return Array.from(str) + .filter((char) => { + const code = char.codePointAt(0); + if (code === undefined) { + return false; + } + if (code === 0x09 || code === 0x0a || code === 0x0d) { + return true; + } + if (code <= 0x1f) { + return false; + } + if (code >= 0xfff9 && code <= 0xfffb) { + return false; + } + return true; + }) + .join(""); +} + +export async function executeShellWithCapture( + env: ExecutionEnv, + command: string, + options?: ShellCaptureOptions, +): Promise> { + const outputChunks: string[] = []; + let outputBytes = 0; + const maxOutputBytes = DEFAULT_MAX_BYTES * 2; + const encoder = new TextEncoder(); + + let totalBytes = 0; + let fullOutputPath: string | undefined; + let writeChain: Promise> = Promise.resolve(ok(undefined)); + let captureError: ExecutionError | undefined; + + const appendFullOutput = (text: string): void => { + if (!fullOutputPath || captureError) { + return; + } + const path = fullOutputPath; + writeChain = writeChain.then(async (previous) => { + if (!previous.ok) { + return previous; + } + const appendResult = await env.appendFile(path, text, options?.abortSignal); + return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error)); + }); + }; + + const ensureFullOutputFile = (initialContent: string): void => { + if (fullOutputPath || captureError) { + return; + } + writeChain = writeChain.then(async (previous) => { + if (!previous.ok) { + return previous; + } + const tempFile = await env.createTempFile({ + prefix: "bash-", + suffix: ".log", + abortSignal: options?.abortSignal, + }); + if (!tempFile.ok) { + return err(toExecutionError(tempFile.error)); + } + fullOutputPath = tempFile.value; + const appendResult = await env.appendFile( + tempFile.value, + initialContent, + options?.abortSignal, + ); + return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error)); + }); + }; + + const onChunk = (chunk: string) => { + try { + totalBytes += encoder.encode(chunk).byteLength; + const text = sanitizeBinaryOutput(chunk).replace(/\r/g, ""); + if (totalBytes > DEFAULT_MAX_BYTES && !fullOutputPath) { + ensureFullOutputFile(outputChunks.join("") + text); + } else { + appendFullOutput(text); + } + outputChunks.push(text); + outputBytes += text.length; + while (outputBytes > maxOutputBytes && outputChunks.length > 1) { + const removed = outputChunks.shift()!; + outputBytes -= removed.length; + } + options?.onChunk?.(text); + } catch (error) { + captureError = toExecutionError(error); + } + }; + + try { + const result = await env.exec(command, { + ...options, + onStdout: onChunk, + onStderr: onChunk, + }); + const tailOutput = outputChunks.join(""); + const truncationResult = truncateTail(tailOutput); + if (truncationResult.truncated && !fullOutputPath) { + ensureFullOutputFile(tailOutput); + } + const writeResult = await writeChain; + if (!writeResult.ok) { + return err(writeResult.error); + } + if (captureError) { + return err(captureError); + } + + if (!result.ok) { + if (result.error.code === "aborted" || options?.abortSignal?.aborted) { + return ok({ + output: truncationResult.truncated ? truncationResult.content : tailOutput, + exitCode: undefined, + cancelled: true, + truncated: truncationResult.truncated, + fullOutputPath, + }); + } + return err(result.error); + } + const cancelled = options?.abortSignal?.aborted ?? false; + return ok({ + output: truncationResult.truncated ? truncationResult.content : tailOutput, + exitCode: cancelled ? undefined : result.value.exitCode, + cancelled, + truncated: truncationResult.truncated, + fullOutputPath, + }); + } catch (error) { + return err(toExecutionError(error)); + } +} diff --git a/packages/agent-core/src/harness/utils/truncate.ts b/packages/agent-core/src/harness/utils/truncate.ts new file mode 100644 index 00000000000..169966eb2f8 --- /dev/null +++ b/packages/agent-core/src/harness/utils/truncate.ts @@ -0,0 +1,361 @@ +/** + * Shared truncation utilities for tool outputs. + * + * Truncation is based on two independent limits - whichever is hit first wins: + * - Line limit (default: 2000 lines) + * - Byte limit (default: 50KB) + * + * Never returns partial lines (except bash tail truncation edge case). + */ + +export const DEFAULT_MAX_LINES = 2000; +export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB +export const GREP_MAX_LINE_LENGTH = 500; // Max chars per grep match line + +export interface TruncationResult { + /** The truncated content */ + content: string; + /** Whether truncation occurred */ + truncated: boolean; + /** Which limit was hit: "lines", "bytes", or null if not truncated */ + truncatedBy: "lines" | "bytes" | null; + /** Total number of lines in the original content */ + totalLines: number; + /** Total number of bytes in the original content */ + totalBytes: number; + /** Number of complete lines in the truncated output */ + outputLines: number; + /** Number of bytes in the truncated output */ + outputBytes: number; + /** Whether the last line was partially truncated (only for tail truncation edge case) */ + lastLinePartial: boolean; + /** Whether the first line exceeded the byte limit (for head truncation) */ + firstLineExceedsLimit: boolean; + /** The max lines limit that was applied */ + maxLines: number; + /** The max bytes limit that was applied */ + maxBytes: number; +} + +export interface TruncationOptions { + /** Maximum number of lines (default: 2000) */ + maxLines?: number; + /** Maximum number of bytes (default: 50KB) */ + maxBytes?: number; +} + +interface RuntimeBuffer { + byteLength(content: string, encoding: "utf8"): number; +} + +const runtimeBuffer = (globalThis as { Buffer?: RuntimeBuffer }).Buffer; + +function findFirstNonAscii(content: string): number { + for (let index = 0; index < content.length; index++) { + if (content.charCodeAt(index) > 0x7f) { + return index; + } + } + return -1; +} + +function utf8ByteLength(content: string): number { + if (runtimeBuffer) { + return runtimeBuffer.byteLength(content, "utf8"); + } + + const firstNonAscii = findFirstNonAscii(content); + if (firstNonAscii === -1) { + return content.length; + } + + let bytes = firstNonAscii; + for (let i = firstNonAscii; i < content.length; i++) { + const code = content.charCodeAt(i); + if (code <= 0x7f) { + bytes += 1; + } else if (code <= 0x7ff) { + bytes += 2; + } else if (code >= 0xd800 && code <= 0xdbff && i + 1 < content.length) { + const next = content.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4; + i++; + } else { + bytes += 3; + } + } else { + bytes += 3; + } + } + return bytes; +} + +function replaceUnpairedSurrogates(content: string): string { + let output = ""; + for (let i = 0; i < content.length; i++) { + const code = content.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + if (i + 1 < content.length) { + const next = content.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + output += content[i] + content[i + 1]; + i++; + continue; + } + } + output += "�"; + } else if (code >= 0xdc00 && code <= 0xdfff) { + output += "�"; + } else { + output += content[i]; + } + } + return output; +} + +/** + * Format bytes as human-readable size. + */ +export function formatSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes}B`; + } else if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)}KB`; + } + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} + +/** + * Truncate content from the head (keep first N lines/bytes). + * Suitable for file reads where you want to see the beginning. + * + * Never returns partial lines. If first line exceeds byte limit, + * returns empty content with firstLineExceedsLimit=true. + */ +export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult { + const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + + const totalBytes = utf8ByteLength(content); + const lines = content.split("\n"); + const totalLines = lines.length; + + // Check if no truncation needed + if (totalLines <= maxLines && totalBytes <= maxBytes) { + return { + content, + truncated: false, + truncatedBy: null, + totalLines, + totalBytes, + outputLines: totalLines, + outputBytes: totalBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; + } + + // Check if first line alone exceeds byte limit + const firstLineBytes = utf8ByteLength(lines[0]); + if (firstLineBytes > maxBytes) { + return { + content: "", + truncated: true, + truncatedBy: "bytes", + totalLines, + totalBytes, + outputLines: 0, + outputBytes: 0, + lastLinePartial: false, + firstLineExceedsLimit: true, + maxLines, + maxBytes, + }; + } + + // Collect complete lines that fit + const outputLinesArr: string[] = []; + let outputBytesCount = 0; + let truncatedBy: "lines" | "bytes" = "lines"; + + for (let i = 0; i < lines.length && i < maxLines; i++) { + const line = lines[i]; + const lineBytes = utf8ByteLength(line) + (i > 0 ? 1 : 0); // +1 for newline + + if (outputBytesCount + lineBytes > maxBytes) { + truncatedBy = "bytes"; + break; + } + + outputLinesArr.push(line); + outputBytesCount += lineBytes; + } + + // If we exited due to line limit + if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) { + truncatedBy = "lines"; + } + + const outputContent = outputLinesArr.join("\n"); + const finalOutputBytes = utf8ByteLength(outputContent); + + return { + content: outputContent, + truncated: true, + truncatedBy, + totalLines, + totalBytes, + outputLines: outputLinesArr.length, + outputBytes: finalOutputBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; +} + +/** + * Truncate content from the tail (keep last N lines/bytes). + * Suitable for bash output where you want to see the end (errors, final results). + * + * May return partial first line if the last line of original content exceeds byte limit. + */ +export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult { + const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + + const totalBytes = utf8ByteLength(content); + const lines = content.split("\n"); + if (lines.length > 1 && lines[lines.length - 1] === "") { + lines.pop(); + } + const totalLines = lines.length; + + // Check if no truncation needed + if (totalLines <= maxLines && totalBytes <= maxBytes) { + return { + content, + truncated: false, + truncatedBy: null, + totalLines, + totalBytes, + outputLines: totalLines, + outputBytes: totalBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; + } + + // Work backwards from the end + const outputLinesArr: string[] = []; + let outputBytesCount = 0; + let truncatedBy: "lines" | "bytes" = "lines"; + let lastLinePartial = false; + + for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) { + const line = lines[i]; + const lineBytes = utf8ByteLength(line) + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline + + if (outputBytesCount + lineBytes > maxBytes) { + truncatedBy = "bytes"; + // Edge case: if we haven't added ANY lines yet and this line exceeds maxBytes, + // take the end of the line (partial) + if (outputLinesArr.length === 0) { + const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes); + outputLinesArr.unshift(truncatedLine); + outputBytesCount = utf8ByteLength(truncatedLine); + lastLinePartial = true; + } + break; + } + + outputLinesArr.unshift(line); + outputBytesCount += lineBytes; + } + + // If we exited due to line limit + if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) { + truncatedBy = "lines"; + } + + const outputContent = outputLinesArr.join("\n"); + const finalOutputBytes = utf8ByteLength(outputContent); + + return { + content: outputContent, + truncated: true, + truncatedBy, + totalLines, + totalBytes, + outputLines: outputLinesArr.length, + outputBytes: finalOutputBytes, + lastLinePartial, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; +} + +/** + * Truncate a string to fit within a byte limit (from the end). + * Handles multi-byte UTF-8 characters correctly. + */ +function truncateStringToBytesFromEnd(str: string, maxBytes: number): string { + if (maxBytes <= 0) { + return ""; + } + + let outputBytes = 0; + let start = str.length; + let needsReplacement = false; + for (let i = str.length; i > 0; ) { + let characterStart = i - 1; + const code = str.charCodeAt(characterStart); + let characterBytes: number; + let unpairedSurrogate = false; + if (code >= 0xdc00 && code <= 0xdfff && characterStart > 0) { + const previous = str.charCodeAt(characterStart - 1); + if (previous >= 0xd800 && previous <= 0xdbff) { + characterStart--; + characterBytes = 4; + } else { + characterBytes = 3; + unpairedSurrogate = true; + } + } else if (code >= 0xd800 && code <= 0xdfff) { + characterBytes = 3; + unpairedSurrogate = true; + } else { + characterBytes = code <= 0x7f ? 1 : code <= 0x7ff ? 2 : 3; + } + if (outputBytes + characterBytes > maxBytes) { + break; + } + outputBytes += characterBytes; + start = characterStart; + needsReplacement ||= unpairedSurrogate; + i = characterStart; + } + + const output = str.slice(start); + return needsReplacement ? replaceUnpairedSurrogates(output) : output; +} + +/** + * Truncate a single line to max characters, adding [truncated] suffix. + * Used for grep match lines. + */ +export function truncateLine( + line: string, + maxChars: number = GREP_MAX_LINE_LENGTH, +): { text: string; wasTruncated: boolean } { + if (line.length <= maxChars) { + return { text: line, wasTruncated: false }; + } + return { text: `${line.slice(0, maxChars)}... [truncated]`, wasTruncated: true }; +} diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts new file mode 100644 index 00000000000..14f25d9ed27 --- /dev/null +++ b/packages/agent-core/src/index.ts @@ -0,0 +1,41 @@ +export * from "./agent.js"; +export * from "./agent-loop.js"; +export * from "./node.js"; +export * from "./types.js"; +export * from "./harness/agent-harness.js"; +export * from "./harness/messages.js"; +export * from "./harness/prompt-templates.js"; +export * from "./harness/skills.js"; +export * from "./harness/system-prompt.js"; +export * from "./harness/types.js"; +export * from "./harness/session/jsonl-repo.js"; +export * from "./harness/session/jsonl-storage.js"; +export * from "./harness/session/memory-repo.js"; +export * from "./harness/session/memory-storage.js"; +export * from "./harness/session/repo-utils.js"; +export * from "./harness/session/session.js"; +export { uuidv7 } from "./harness/session/uuid.js"; +export { + type BranchPreparation, + type BranchSummaryDetails, + type CollectEntriesResult, + collectEntriesForBranchSummary, + generateBranchSummary, + prepareBranchEntries, +} from "./harness/compaction/branch-summarization.js"; +export { + calculateContextTokens, + compact, + DEFAULT_COMPACTION_SETTINGS, + estimateContextTokens, + estimateTokens, + findCutPoint, + findTurnStartIndex, + generateSummary, + getLastAssistantUsage, + prepareCompaction, + serializeConversation, + shouldCompact, +} from "./harness/compaction/compaction.js"; +export * from "./harness/utils/shell-output.js"; +export * from "./harness/utils/truncate.js"; diff --git a/packages/agent-core/src/node.ts b/packages/agent-core/src/node.ts new file mode 100644 index 00000000000..23ccae43bf3 --- /dev/null +++ b/packages/agent-core/src/node.ts @@ -0,0 +1,2 @@ +export { NodeExecutionEnv } from "./harness/env/nodejs.js"; +export * from "./index.js"; diff --git a/packages/agent-core/src/types.ts b/packages/agent-core/src/types.ts new file mode 100644 index 00000000000..ad958e9cd73 --- /dev/null +++ b/packages/agent-core/src/types.ts @@ -0,0 +1,439 @@ +import type { + AssistantMessage, + AssistantMessageEvent, + ImageContent, + Message, + Model, + SimpleStreamOptions, + streamSimple, + TextContent, + Tool, + ToolResultMessage, +} from "openclaw/plugin-sdk/llm"; +import type { Static, TSchema } from "typebox"; + +/** + * Stream function used by the agent loop. + * + * Contract: + * - Must not throw or return a rejected promise for request/model/runtime failures. + * - Must return an AssistantMessageEventStream. + * - Failures must be encoded in the returned stream via protocol events and a + * final AssistantMessage with stopReason "error" or "aborted" and errorMessage. + */ +export type StreamFn = ( + ...args: Parameters +) => ReturnType | Promise>; + +/** + * Configuration for how tool calls from a single assistant message are executed. + * + * - "sequential": each tool call is prepared, executed, and finalized before the next one starts. + * - "parallel": tool calls are prepared sequentially, then allowed tools execute concurrently. + * `tool_execution_end` is emitted in tool completion order after each tool is finalized, + * while tool-result message artifacts are emitted later in assistant source order. + */ +export type ToolExecutionMode = "sequential" | "parallel"; + +/** + * Controls how many queued user messages are injected when the agent loop reaches a queue drain point. + * + * - "all": drain and inject every queued message at that point. + * - "one-at-a-time": drain and inject only the oldest queued message, leaving the rest queued for later drain points. + */ +export type QueueMode = "all" | "one-at-a-time"; + +/** A single tool call content block emitted by an assistant message. */ +export type AgentToolCall = Extract; + +/** + * Result returned from `beforeToolCall`. + * + * Returning `{ block: true }` prevents the tool from executing. The loop emits an error tool result instead. + * `reason` becomes the text shown in that error result. If omitted, a default blocked message is used. + */ +export interface BeforeToolCallResult { + block?: boolean; + reason?: string; +} + +/** + * Partial override returned from `afterToolCall`. + * + * Merge semantics are field-by-field: + * - `content`: if provided, replaces the tool result content array in full + * - `details`: if provided, replaces the tool result details value in full + * - `isError`: if provided, replaces the tool result error flag + * - `terminate`: if provided, replaces the early-termination hint + * + * Omitted fields keep the original executed tool result values. + * There is no deep merge for `content` or `details`. + */ +export interface AfterToolCallResult { + content?: (TextContent | ImageContent)[]; + details?: unknown; + isError?: boolean; + /** + * Hint that the agent should stop after the current tool batch. + * Early termination only happens when every finalized tool result in the batch sets this to true. + */ + terminate?: boolean; +} + +/** Context passed to `beforeToolCall`. */ +export interface BeforeToolCallContext { + /** The assistant message that requested the tool call. */ + assistantMessage: AssistantMessage; + /** The raw tool call block from `assistantMessage.content`. */ + toolCall: AgentToolCall; + /** Validated tool arguments for the target tool schema. */ + args: unknown; + /** Current agent context at the time the tool call is prepared. */ + context: AgentContext; +} + +/** Context passed to `afterToolCall`. */ +export interface AfterToolCallContext { + /** The assistant message that requested the tool call. */ + assistantMessage: AssistantMessage; + /** The raw tool call block from `assistantMessage.content`. */ + toolCall: AgentToolCall; + /** Validated tool arguments for the target tool schema. */ + args: unknown; + /** The executed tool result before unknown `afterToolCall` overrides are applied. */ + result: AgentToolResult; + /** Whether the executed tool result is currently treated as an error. */ + isError: boolean; + /** Current agent context at the time the tool call is finalized. */ + context: AgentContext; +} + +/** Context passed to `shouldStopAfterTurn`. */ +export interface ShouldStopAfterTurnContext { + /** The assistant message that completed the turn. */ + message: AssistantMessage; + /** Tool result messages passed to the preceding `turn_end` event. */ + toolResults: ToolResultMessage[]; + /** Current agent context after the turn's assistant message and tool results have been appended. */ + context: AgentContext; + /** Messages that this loop invocation will return if it exits at this point. Prompt runs include the initial prompt messages; continuation runs do not include pre-existing context messages. */ + newMessages: AgentMessage[]; +} + +/** Replacement runtime state used by the agent loop before starting another provider request. */ +export interface AgentLoopTurnUpdate { + /** Context for the next provider request. */ + context?: AgentContext; + /** Model for the next provider request. */ + model?: Model; + /** Thinking level for the next provider request. */ + thinkingLevel?: ThinkingLevel; +} + +export interface PrepareNextTurnContext extends ShouldStopAfterTurnContext {} + +export interface AgentLoopConfig extends SimpleStreamOptions { + model: Model; + + /** + * Converts AgentMessage[] to LLM-compatible Message[] before each LLM call. + * + * Each AgentMessage must be converted to a UserMessage, AssistantMessage, or ToolResultMessage + * that the LLM can understand. AgentMessages that cannot be converted (e.g., UI-only notifications, + * status messages) should be filtered out. + * + * Contract: must not throw or reject. Return a safe fallback value instead. + * Throwing interrupts the low-level agent loop without producing a normal event sequence. + * + * @example + * ```typescript + * convertToLlm: (messages) => messages.flatMap(m => { + * if (m.role === "custom") { + * // Convert custom message to user message + * return [{ role: "user", content: m.content, timestamp: m.timestamp }]; + * } + * if (m.role === "notification") { + * // Filter out UI-only messages + * return []; + * } + * // Pass through standard LLM messages + * return [m]; + * }) + * ``` + */ + convertToLlm: (messages: AgentMessage[]) => Message[] | Promise; + + /** + * Optional transform applied to the context before `convertToLlm`. + * + * Use this for operations that work at the AgentMessage level: + * - Context window management (pruning old messages) + * - Injecting context from external sources + * + * Contract: must not throw or reject. Return the original messages or another + * safe fallback value instead. + * + * @example + * ```typescript + * transformContext: async (messages) => { + * if (estimateTokens(messages) > MAX_TOKENS) { + * return pruneOldMessages(messages); + * } + * return messages; + * } + * ``` + */ + transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; + + /** + * Resolves an API key dynamically for each LLM call. + * + * Useful for short-lived OAuth tokens (e.g., GitHub Copilot) that may expire + * during long-running tool execution phases. + * + * Contract: must not throw or reject. Return undefined when no key is available. + */ + getApiKey?: (provider: string) => Promise | string | undefined; + + /** + * Called after each turn fully completes and `turn_end` has been emitted. + * + * If it returns true, the loop emits `agent_end` and exits before polling steering or follow-up queues, + * without starting another LLM call. The current assistant response and any tool executions finish normally. + * + * Use this to request a graceful stop after the current turn, e.g. before context gets too full. + * + * Contract: must not throw or reject. Throwing interrupts the low-level agent loop without producing a normal event sequence. + */ + shouldStopAfterTurn?: (context: ShouldStopAfterTurnContext) => boolean | Promise; + + /** + * Called after `turn_end` and before the loop decides whether another provider request should start. + * Return replacement context/model/thinking state to affect the next turn in this run. + * Return undefined to keep using the current context/config. + */ + prepareNextTurn?: ( + context: PrepareNextTurnContext, + ) => AgentLoopTurnUpdate | undefined | Promise; + + /** + * Returns steering messages to inject into the conversation mid-run. + * + * Called after the current assistant turn finishes executing its tool calls, unless `shouldStopAfterTurn` exits first. + * If messages are returned, they are added to the context before the next LLM call. + * Tool calls from the current assistant message are not skipped. + * + * Use this for "steering" the agent while it's working. + * + * Contract: must not throw or reject. Return [] when no steering messages are available. + */ + getSteeringMessages?: () => Promise; + + /** + * Returns follow-up messages to process after the agent would otherwise stop. + * + * Called when the agent has no more tool calls and no steering messages. + * If messages are returned, they're added to the context and the agent + * continues with another turn. + * + * Use this for follow-up messages that should wait until the agent finishes. + * + * Contract: must not throw or reject. Return [] when no follow-up messages are available. + */ + getFollowUpMessages?: () => Promise; + + /** + * Tool execution mode. + * - "sequential": execute tool calls one by one + * - "parallel": preflight tool calls sequentially, then execute allowed tools concurrently; + * emit `tool_execution_end` in tool completion order after each tool is finalized, + * then emit tool-result message artifacts later in assistant source order + * + * Default: "parallel" + */ + toolExecution?: ToolExecutionMode; + + /** + * Called before a tool is executed, after arguments have been validated. + * + * Return `{ block: true }` to prevent execution. The loop emits an error tool result instead. + * The hook receives the agent abort signal and is responsible for honoring it. + */ + beforeToolCall?: ( + context: BeforeToolCallContext, + signal?: AbortSignal, + ) => Promise; + + /** + * Called after a tool finishes executing, before `tool_execution_end` and tool-result message events are emitted. + * + * Return an `AfterToolCallResult` to override parts of the executed tool result: + * - `content` replaces the full content array + * - `details` replaces the full details payload + * - `isError` replaces the error flag + * - `terminate` replaces the early-termination hint + * + * Any omitted fields keep their original values. No deep merge is performed. + * The hook receives the agent abort signal and is responsible for honoring it. + */ + afterToolCall?: ( + context: AfterToolCallContext, + signal?: AbortSignal, + ) => Promise; +} + +/** + * Thinking/reasoning level for models that support it. + * Note: "xhigh" is only supported by selected model families. Use model thinking-level metadata + * from openclaw/plugin-sdk/llm to detect support for a concrete model. + */ +export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + +/** + * Extensible interface for custom app messages. + * Apps can extend via declaration merging: + * + * @example + * ```typescript + * declare module "@mariozechner/agent" { + * interface CustomAgentMessages { + * artifact: ArtifactMessage; + * notification: NotificationMessage; + * } + * } + * ``` + */ +export interface CustomAgentMessages extends Record { + // Empty by default - apps extend via declaration merging +} + +/** + * AgentMessage: Union of LLM messages + custom messages. + * This abstraction allows apps to add custom message types while maintaining + * type safety and compatibility with the base LLM messages. + */ +export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages]; + +/** + * Public agent state. + * + * `tools` and `messages` use accessor properties so implementations can copy + * assigned arrays before storing them. + */ +export interface AgentState { + /** System prompt sent with each model request. */ + systemPrompt: string; + /** Active model used for future turns. */ + model: Model; + /** Requested reasoning level for future turns. */ + thinkingLevel: ThinkingLevel; + /** Available tools. Assigning a new array copies the top-level array. */ + set tools(tools: AgentTool[]); + get tools(): AgentTool[]; + /** Conversation transcript. Assigning a new array copies the top-level array. */ + set messages(messages: AgentMessage[]); + get messages(): AgentMessage[]; + /** + * True while the agent is processing a prompt or continuation. + * + * This remains true until awaited `agent_end` listeners settle. + */ + readonly isStreaming: boolean; + /** Partial assistant message for the current streamed response, if any. */ + readonly streamingMessage?: AgentMessage; + /** Tool call ids currently executing. */ + readonly pendingToolCalls: ReadonlySet; + /** Error message from the most recent failed or aborted assistant turn, if any. */ + readonly errorMessage?: string; +} + +/** Final or partial result produced by a tool. */ +export interface AgentToolResult { + /** Text or image content returned to the model. */ + content: (TextContent | ImageContent)[]; + /** Arbitrary structured details for logs or UI rendering. */ + details: T; + /** + * Hint that the agent should stop after the current tool batch. + * Early termination only happens when every finalized tool result in the batch sets this to true. + */ + terminate?: boolean; +} + +/** Callback used by tools to stream partial execution updates. */ +export type AgentToolUpdateCallback = (partialResult: AgentToolResult) => void; + +/** Tool definition used by the agent runtime. */ +export interface AgentTool< + TParameters extends TSchema = TSchema, + TDetails = unknown, +> extends Tool { + /** Human-readable label for UI display. */ + label: string; + /** + * Optional compatibility shim for raw tool-call arguments before schema validation. + * Must return an object that matches `TParameters`. + */ + prepareArguments?: (args: unknown) => Static; + /** Execute the tool call. Throw on failure instead of encoding errors in `content`. */ + execute: ( + toolCallId: string, + params: Static, + signal?: AbortSignal, + onUpdate?: AgentToolUpdateCallback, + ) => Promise>; + /** + * Per-tool execution mode override. + * - "sequential": this tool must execute one at a time with other tool calls. + * - "parallel": this tool can execute concurrently with other tool calls. + * + * If omitted, the default execution mode applies. + */ + executionMode?: ToolExecutionMode; +} + +/** Context snapshot passed into the low-level agent loop. */ +export interface AgentContext { + /** System prompt included with the request. */ + systemPrompt: string; + /** Transcript visible to the model. */ + messages: AgentMessage[]; + /** Tools available for this run. */ + tools?: AgentTool[]; +} + +/** + * Events emitted by the Agent for UI updates. + * + * `agent_end` is the last event emitted for a run, but awaited `Agent.subscribe()` + * listeners for that event are still part of run settlement. The agent becomes + * idle only after those listeners finish. + */ +export type AgentEvent = + // Agent lifecycle + | { type: "agent_start" } + | { type: "agent_end"; messages: AgentMessage[] } + // Turn lifecycle - a turn is one assistant response + any tool calls/results + | { type: "turn_start" } + | { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] } + // Message lifecycle - emitted for user, assistant, and toolResult messages + | { type: "message_start"; message: AgentMessage } + // Only emitted for assistant messages during streaming + | { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent } + | { type: "message_end"; message: AgentMessage } + // Tool execution lifecycle + | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: unknown } + | { + type: "tool_execution_update"; + toolCallId: string; + toolName: string; + args: unknown; + partialResult: unknown; + } + | { + type: "tool_execution_end"; + toolCallId: string; + toolName: string; + result: unknown; + isError: boolean; + }; diff --git a/src/agents/runtime/index.ts b/src/agents/runtime/index.ts new file mode 100644 index 00000000000..80f604b9f6a --- /dev/null +++ b/src/agents/runtime/index.ts @@ -0,0 +1,4 @@ +// OpenClaw-owned reusable agent core +export * from "../../../packages/agent-core/src/index.js"; +// Proxy utilities +export * from "./proxy.js"; diff --git a/src/agents/runtime/proxy.ts b/src/agents/runtime/proxy.ts new file mode 100644 index 00000000000..6942bdf2811 --- /dev/null +++ b/src/agents/runtime/proxy.ts @@ -0,0 +1,380 @@ +/** + * Proxy stream function for apps that route LLM calls through a server. + * The server manages auth and proxies requests to LLM providers. + */ + +// Internal import for JSON parsing utility +import { + type AssistantMessage, + type AssistantMessageEvent, + type Context, + EventStream, + type Model, + parseStreamingJson, + type SimpleStreamOptions, + type StopReason, + type ToolCall, +} from "openclaw/plugin-sdk/llm"; + +type StreamingToolCall = ToolCall & { partialJson?: string }; + +// Create stream class matching ProxyMessageEventStream +class ProxyMessageEventStream extends EventStream { + constructor() { + super( + (event) => event.type === "done" || event.type === "error", + (event) => { + if (event.type === "done") { + return event.message; + } + if (event.type === "error") { + return event.error; + } + throw new Error("Unexpected event type"); + }, + ); + } +} + +/** + * Proxy event types - server sends these with partial field stripped to reduce bandwidth. + */ +export type ProxyAssistantMessageEvent = + | { type: "start" } + | { type: "text_start"; contentIndex: number } + | { type: "text_delta"; contentIndex: number; delta: string } + | { type: "text_end"; contentIndex: number; contentSignature?: string } + | { type: "thinking_start"; contentIndex: number } + | { type: "thinking_delta"; contentIndex: number; delta: string } + | { type: "thinking_end"; contentIndex: number; contentSignature?: string } + | { type: "toolcall_start"; contentIndex: number; id: string; toolName: string } + | { type: "toolcall_delta"; contentIndex: number; delta: string } + | { type: "toolcall_end"; contentIndex: number } + | { + type: "done"; + reason: Extract; + usage: AssistantMessage["usage"]; + } + | { + type: "error"; + reason: Extract; + errorMessage?: string; + usage: AssistantMessage["usage"]; + }; + +type ProxySerializableStreamOptions = Pick< + SimpleStreamOptions, + | "temperature" + | "maxTokens" + | "reasoning" + | "cacheRetention" + | "sessionId" + | "headers" + | "metadata" + | "transport" + | "thinkingBudgets" + | "maxRetryDelayMs" +>; + +export interface ProxyStreamOptions extends ProxySerializableStreamOptions { + /** Local abort signal for the proxy request */ + signal?: AbortSignal; + /** Auth token for the proxy server */ + authToken: string; + /** Proxy server URL (e.g., "https://genai.example.com") */ + proxyUrl: string; +} + +/** + * Stream function that proxies through a server instead of calling LLM providers directly. + * The server strips the partial field from delta events to reduce bandwidth. + * We reconstruct the partial message client-side. + * + * Use this as the `streamFn` option when creating an Agent that needs to go through a proxy. + * + * @example + * ```typescript + * const agent = new Agent({ + * streamFn: (model, context, options) => + * streamProxy(model, context, { + * ...options, + * authToken: await getAuthToken(), + * proxyUrl: "https://genai.example.com", + * }), + * }); + * ``` + */ +function buildProxyRequestOptions(options: ProxyStreamOptions): ProxySerializableStreamOptions { + return { + temperature: options.temperature, + maxTokens: options.maxTokens, + reasoning: options.reasoning, + cacheRetention: options.cacheRetention, + sessionId: options.sessionId, + headers: options.headers, + metadata: options.metadata, + transport: options.transport, + thinkingBudgets: options.thinkingBudgets, + maxRetryDelayMs: options.maxRetryDelayMs, + }; +} + +export function streamProxy( + model: Model, + context: Context, + options: ProxyStreamOptions, +): ProxyMessageEventStream { + const stream = new ProxyMessageEventStream(); + + void (async () => { + // Initialize the partial message that we'll build up from events + const partial: AssistantMessage = { + role: "assistant", + stopReason: "stop", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: Date.now(), + }; + + let reader: ReadableStreamDefaultReader | undefined; + + const abortHandler = () => { + if (reader) { + reader.cancel("Request aborted by user").catch(() => {}); + } + }; + + if (options.signal) { + options.signal.addEventListener("abort", abortHandler); + } + + try { + const response = await fetch(`${options.proxyUrl}/api/stream`, { + method: "POST", + headers: { + Authorization: `Bearer ${options.authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model, + context, + options: buildProxyRequestOptions(options), + }), + signal: options.signal, + }); + + if (!response.ok) { + let errorMessage = `Proxy error: ${response.status} ${response.statusText}`; + try { + const errorData = (await response.json()) as { error?: string }; + if (errorData.error) { + errorMessage = `Proxy error: ${errorData.error}`; + } + } catch { + // Couldn't parse error response + } + throw new Error(errorMessage); + } + + reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + if (options.signal?.aborted) { + throw new Error("Request aborted by user"); + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + const data = line.slice(6).trim(); + if (data) { + const proxyEvent = JSON.parse(data) as ProxyAssistantMessageEvent; + const event = processProxyEvent(proxyEvent, partial); + if (event) { + stream.push(event); + } + } + } + } + } + + if (options.signal?.aborted) { + throw new Error("Request aborted by user"); + } + + stream.end(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const reason = options.signal?.aborted ? "aborted" : "error"; + partial.stopReason = reason; + partial.errorMessage = errorMessage; + stream.push({ + type: "error", + reason, + error: partial, + }); + stream.end(); + } finally { + if (options.signal) { + options.signal.removeEventListener("abort", abortHandler); + } + } + })(); + + return stream; +} + +/** + * Process a proxy event and update the partial message. + */ +function processProxyEvent( + proxyEvent: ProxyAssistantMessageEvent, + partial: AssistantMessage, +): AssistantMessageEvent | undefined { + switch (proxyEvent.type) { + case "start": + return { type: "start", partial }; + + case "text_start": + partial.content[proxyEvent.contentIndex] = { type: "text", text: "" }; + return { type: "text_start", contentIndex: proxyEvent.contentIndex, partial }; + + case "text_delta": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "text") { + content.text += proxyEvent.delta; + return { + type: "text_delta", + contentIndex: proxyEvent.contentIndex, + delta: proxyEvent.delta, + partial, + }; + } + throw new Error("Received text_delta for non-text content"); + } + + case "text_end": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "text") { + content.textSignature = proxyEvent.contentSignature; + return { + type: "text_end", + contentIndex: proxyEvent.contentIndex, + content: content.text, + partial, + }; + } + throw new Error("Received text_end for non-text content"); + } + + case "thinking_start": + partial.content[proxyEvent.contentIndex] = { type: "thinking", thinking: "" }; + return { type: "thinking_start", contentIndex: proxyEvent.contentIndex, partial }; + + case "thinking_delta": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "thinking") { + content.thinking += proxyEvent.delta; + return { + type: "thinking_delta", + contentIndex: proxyEvent.contentIndex, + delta: proxyEvent.delta, + partial, + }; + } + throw new Error("Received thinking_delta for non-thinking content"); + } + + case "thinking_end": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "thinking") { + content.thinkingSignature = proxyEvent.contentSignature; + return { + type: "thinking_end", + contentIndex: proxyEvent.contentIndex, + content: content.thinking, + partial, + }; + } + throw new Error("Received thinking_end for non-thinking content"); + } + + case "toolcall_start": + partial.content[proxyEvent.contentIndex] = { + type: "toolCall", + id: proxyEvent.id, + name: proxyEvent.toolName, + arguments: {}, + partialJson: "", + } satisfies ToolCall & { partialJson: string } as ToolCall; + return { type: "toolcall_start", contentIndex: proxyEvent.contentIndex, partial }; + + case "toolcall_delta": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "toolCall") { + const streamingContent = content as StreamingToolCall; + streamingContent.partialJson = `${streamingContent.partialJson ?? ""}${proxyEvent.delta}`; + content.arguments = parseStreamingJson(streamingContent.partialJson) || {}; + partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity + return { + type: "toolcall_delta", + contentIndex: proxyEvent.contentIndex, + delta: proxyEvent.delta, + partial, + }; + } + throw new Error("Received toolcall_delta for non-toolCall content"); + } + + case "toolcall_end": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "toolCall") { + delete (content as StreamingToolCall).partialJson; + return { + type: "toolcall_end", + contentIndex: proxyEvent.contentIndex, + toolCall: content, + partial, + }; + } + return undefined; + } + + case "done": + partial.stopReason = proxyEvent.reason; + partial.usage = proxyEvent.usage; + return { type: "done", reason: proxyEvent.reason, message: partial }; + + case "error": + partial.stopReason = proxyEvent.reason; + partial.errorMessage = proxyEvent.errorMessage; + partial.usage = proxyEvent.usage; + return { type: "error", reason: proxyEvent.reason, error: partial }; + + default: { + proxyEvent satisfies never; + console.warn(`Unhandled proxy event type: ${(proxyEvent as { type?: string }).type}`); + return undefined; + } + } +} diff --git a/tsconfig.json b/tsconfig.json index 1d0a2eae679..9e35ca92e9d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,6 +27,8 @@ "openclaw/extension-api": ["./src/extensionAPI.ts"], "openclaw/plugin-sdk": ["./src/plugin-sdk/index.ts"], "openclaw/plugin-sdk/*": ["./src/plugin-sdk/*.ts"], + "@openclaw/agent-core": ["./packages/agent-core/src/index.ts"], + "@openclaw/agent-core/*": ["./packages/agent-core/src/*"], "@openclaw/sdk": ["./packages/sdk/src/index.ts"], "@openclaw/plugin-sdk": ["./src/plugin-sdk/index.ts"], "@openclaw/plugin-sdk/*": ["./src/plugin-sdk/*.ts"], diff --git a/tsdown.config.ts b/tsdown.config.ts index fb8ed496577..a65a2483dc2 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -242,7 +242,6 @@ function buildCoreDistEntries(): Record { "plugins/synthetic-auth.runtime": "src/plugins/synthetic-auth.runtime.ts", "subagent-registry.runtime": "src/agents/subagent-registry.runtime.ts", "task-registry-control.runtime": "src/tasks/task-registry-control.runtime.ts", - "agents/pi-model-discovery-runtime": "src/agents/pi-model-discovery-runtime.ts", "link-understanding/apply.runtime": "src/link-understanding/apply.runtime.ts", "media-understanding/apply.runtime": "src/media-understanding/apply.runtime.ts", "commands/doctor/shared/plugin-registry-migration": @@ -266,6 +265,7 @@ function buildCoreDistEntries(): Record { "telegram/token": bundledPluginFile("telegram", "src/token.ts"), "plugins/build-smoke-entry": "src/plugins/build-smoke-entry.ts", "plugins/runtime/index": "src/plugins/runtime/index.ts", + "llm/models.generated": "src/llm/models.generated.ts", "llm-slug-generator": "src/hooks/llm-slug-generator.ts", "mcp/plugin-tools-serve": "src/mcp/plugin-tools-serve.ts", }; @@ -275,13 +275,13 @@ function buildDockerE2eHarnessEntries(): Record { return { // Mounted Docker harnesses run against the npm tarball image, so any // internal module they assert must have a stable package dist entry. - "agents/pi-bundle-mcp-materialize": "src/agents/pi-bundle-mcp-materialize.ts", - "agents/pi-bundle-mcp-runtime": "src/agents/pi-bundle-mcp-runtime.ts", - "agents/pi-embedded-runner/effective-tool-policy": - "src/agents/pi-embedded-runner/effective-tool-policy.ts", - "agents/pi-embedded-runner/tool-split": "src/agents/pi-embedded-runner/tool-split.ts", - "agents/pi-embedded-runner/run/runtime-context-prompt": - "src/agents/pi-embedded-runner/run/runtime-context-prompt.ts", + "agents/agent-bundle-mcp-materialize": "src/agents/agent-bundle-mcp-materialize.ts", + "agents/agent-bundle-mcp-runtime": "src/agents/agent-bundle-mcp-runtime.ts", + "agents/embedded-agent-runner/effective-tool-policy": + "src/agents/embedded-agent-runner/effective-tool-policy.ts", + "agents/embedded-agent-runner/tool-split": "src/agents/embedded-agent-runner/tool-split.ts", + "agents/embedded-agent-runner/run/runtime-context-prompt": + "src/agents/embedded-agent-runner/run/runtime-context-prompt.ts", "auto-reply/reply/commands-crestodian": "src/auto-reply/reply/commands-crestodian.ts", "cli/run-main": "src/cli/run-main.ts", "commitments/runtime": "src/commitments/runtime.ts", @@ -298,6 +298,46 @@ function buildDockerE2eHarnessEntries(): Record { }; } +function buildAgentCoreDistEntries(): Record { + return { + index: "packages/agent-core/src/index.ts", + agent: "packages/agent-core/src/agent.ts", + "agent-loop": "packages/agent-core/src/agent-loop.ts", + node: "packages/agent-core/src/node.ts", + types: "packages/agent-core/src/types.ts", + "harness/agent-harness": "packages/agent-core/src/harness/agent-harness.ts", + "harness/types": "packages/agent-core/src/harness/types.ts", + "harness/messages": "packages/agent-core/src/harness/messages.ts", + "harness/session": "packages/agent-core/src/harness/session/session.ts", + "harness/session/jsonl-repo": "packages/agent-core/src/harness/session/jsonl-repo.ts", + "harness/session/jsonl-storage": "packages/agent-core/src/harness/session/jsonl-storage.ts", + "harness/session/memory-repo": "packages/agent-core/src/harness/session/memory-repo.ts", + "harness/session/memory-storage": "packages/agent-core/src/harness/session/memory-storage.ts", + "harness/session/repo-utils": "packages/agent-core/src/harness/session/repo-utils.ts", + "harness/session/uuid": "packages/agent-core/src/harness/session/uuid.ts", + "harness/compaction": "packages/agent-core/src/harness/compaction/compaction.ts", + "harness/branch-summarization": + "packages/agent-core/src/harness/compaction/branch-summarization.ts", + "harness/prompt-templates": "packages/agent-core/src/harness/prompt-templates.ts", + "harness/skills": "packages/agent-core/src/harness/skills.ts", + "harness/system-prompt": "packages/agent-core/src/harness/system-prompt.ts", + "harness/utils/shell-output": "packages/agent-core/src/harness/utils/shell-output.ts", + "harness/utils/truncate": "packages/agent-core/src/harness/utils/truncate.ts", + }; +} + +function shouldExternalizeAgentCoreDependency(id: string): boolean { + return ( + id === "ignore" || + id === "openclaw" || + id.startsWith("openclaw/") || + id === "typebox" || + id.startsWith("typebox/") || + id === "yaml" || + id.startsWith("yaml/") + ); +} + const coreDistEntries = buildCoreDistEntries(); const dockerE2eHarnessEntries = buildDockerE2eHarnessEntries(); const rootBundledPluginBuildEntries = bundledPluginBuildEntries.filter( @@ -332,6 +372,15 @@ function buildUnifiedDistEntries(): Record { } export default defineConfig([ + nodeBuildConfig({ + clean: true, + dts: RUN_NODE_SKIP_DTS_BUILD ? false : undefined, + entry: buildAgentCoreDistEntries(), + outDir: "packages/agent-core/dist", + deps: { + neverBundle: shouldExternalizeAgentCoreDependency, + }, + }), nodeBuildConfig({ // Build core entrypoints, plugin-sdk subpaths, bundled plugin entrypoints, // and bundled hooks in one graph so runtime singletons are emitted once.