diff --git a/config/knip.config.ts b/config/knip.config.ts index 9e1aabd36b84..4d54fd057f43 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -427,22 +427,14 @@ const config = { "src/agent.ts!", "src/agent-loop.ts!", "src/llm.ts!", - "src/node.ts!", "src/runtime-deps.ts!", "src/validation.ts!", "src/types.ts!", - "src/harness/agent-harness.ts!", - "src/harness/types.ts!", "src/harness/messages.ts!", "src/harness/env/kill-tree.ts!", - "src/harness/session.ts!", - "src/harness/session/jsonl-storage.ts!", - "src/harness/session/memory-storage.ts!", - "src/harness/session/uuid.ts!", "src/harness/compaction.ts!", "src/harness/branch-summarization.ts!", "src/harness/prompt-template-arguments.ts!", - "src/harness/skills.ts!", "src/harness/utils/truncate.ts!", ], project: ["src/**/*.ts!"], diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 01931ee4465b..086a12d19a95 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -348,7 +348,6 @@ extensions/zalouser/src/monitor.ts extensions/zalouser/src/zalo-js.ts packages/agent-core/src/agent-loop.test.ts packages/agent-core/src/agent-loop.ts -packages/agent-core/src/harness/agent-harness.ts packages/agent-core/src/harness/compaction/compaction.ts packages/ai/src/providers/agent-tools-parameter-schema.ts packages/ai/src/providers/anthropic.test.ts diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json index 849f92332bd2..ef604ca50780 100644 --- a/packages/agent-core/package.json +++ b/packages/agent-core/package.json @@ -25,10 +25,6 @@ "types": "./dist/llm.d.ts", "default": "./dist/llm.js" }, - "./node": { - "types": "./dist/node.d.ts", - "default": "./dist/node.js" - }, "./runtime-deps": { "types": "./dist/runtime-deps.d.ts", "default": "./dist/runtime-deps.js" @@ -41,14 +37,6 @@ "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" @@ -57,22 +45,6 @@ "types": "./dist/harness/env/kill-tree.d.ts", "default": "./dist/harness/env/kill-tree.js" }, - "./harness/session": { - "types": "./dist/harness/session.d.ts", - "default": "./dist/harness/session.js" - }, - "./harness/session/jsonl-storage": { - "types": "./dist/harness/session/jsonl-storage.d.ts", - "default": "./dist/harness/session/jsonl-storage.js" - }, - "./harness/session/memory-storage": { - "types": "./dist/harness/session/memory-storage.d.ts", - "default": "./dist/harness/session/memory-storage.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" @@ -85,10 +57,6 @@ "types": "./dist/harness/prompt-template-arguments.d.ts", "default": "./dist/harness/prompt-template-arguments.js" }, - "./harness/skills": { - "types": "./dist/harness/skills.d.ts", - "default": "./dist/harness/skills.js" - }, "./harness/utils/truncate": { "types": "./dist/harness/utils/truncate.d.ts", "default": "./dist/harness/utils/truncate.js" @@ -96,6 +64,7 @@ }, "dependencies": { "@openclaw/ai": "workspace:*", + "@openclaw/llm-core": "workspace:*", "@openclaw/normalization-core": "workspace:*", "typebox": "1.3.3" } diff --git a/packages/agent-core/src/agent-loop.ts b/packages/agent-core/src/agent-loop.ts index c03258171c50..de6e94a7db94 100644 --- a/packages/agent-core/src/agent-loop.ts +++ b/packages/agent-core/src/agent-loop.ts @@ -7,8 +7,8 @@ import type { Context, EventStream, ToolResultMessage, -} from "../../llm-core/src/index.js"; -import type { EventStream as SourceEventStream } from "../../llm-core/src/index.js"; +} from "@openclaw/llm-core"; +import type { EventStream as SourceEventStream } from "@openclaw/llm-core"; import { TranscriptNotContinuableError } from "./errors.js"; import { resolveAgentReasoningOption } from "./reasoning.js"; import { type AgentCoreStreamRuntimeDeps, resolveAgentCoreStreamFn } from "./runtime-deps.js"; diff --git a/packages/agent-core/src/agent.ts b/packages/agent-core/src/agent.ts index 93422b519083..a884bb675ce4 100644 --- a/packages/agent-core/src/agent.ts +++ b/packages/agent-core/src/agent.ts @@ -7,7 +7,7 @@ import type { TextContent, ThinkingBudgets, Transport, -} from "../../llm-core/src/index.js"; +} from "@openclaw/llm-core"; import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.js"; import { TranscriptNotContinuableError } from "./errors.js"; import { resolveAgentReasoningOption } from "./reasoning.js"; diff --git a/packages/agent-core/src/harness/agent-harness.ts b/packages/agent-core/src/harness/agent-harness.ts deleted file mode 100644 index ba29b556748d..000000000000 --- a/packages/agent-core/src/harness/agent-harness.ts +++ /dev/null @@ -1,1198 +0,0 @@ -// Agent Core module implements agent harness behavior. -import { toErrorObject } from "@openclaw/normalization-core/error-coercion"; -import type { - AssistantMessage, - ImageContent, - Model, - UserMessage, -} from "../../../llm-core/src/index.js"; -import { runAgentLoop } from "../agent-loop.js"; -import { resolveAgentReasoningOption } from "../reasoning.js"; -import { type AgentCoreRuntimeDeps, resolveAgentCoreStreamFn } from "../runtime-deps.js"; -import { - appendInterruptedTurnMessage, - createFailureMessage, - isTurnHandoffAbort, -} from "../turn-interruption.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-template-arguments.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 } from "./types.js"; - -// CoreAgentHarness coordinates session state, resources, tools, compaction, and -// streaming callbacks around the lower-level agent loop. -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 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 = toErrorObject(error, "Non-Error thrown"); - 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[]; -} - -/** Stateful harness for running, steering, compacting, and navigating sessions. */ -export class CoreAgentHarness< - 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 runtime?: AgentCoreRuntimeDeps; - 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; - this.runtime = options.runtime; - 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 resolveAgentCoreStreamFn(this.runtime)(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, - thinkingLevel: turnState.thinkingLevel, - reasoning: resolveAgentReasoningOption(turnState.model, 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.at(0); - if (!write) { - break; - } - 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 toLintErrorObject(eventError, "Non-Error thrown"); - } - 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, - ); - const messages: AgentMessage[] = [failureMessage]; - if (aborted && !isTurnHandoffAbort(signal)) { - await appendInterruptedTurnMessage(messages, (event) => this.handleAgentEvent(event, signal)); - } - await this.handleAgentEvent({ type: "agent_end", messages }, signal); - return messages; - } - - private async executeTurn( - turnState: AgentHarnessTurnState, - text: string, - options?: { images?: ImageContent[] }, - ): Promise { - let activeTurnState = turnState; - const promptMessage = createUserMessage(text, options?.images); - let messages: AgentMessage[] = [promptMessage]; - 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, promptMessage]; - } - 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( - [error, failureError].map((value) => toErrorObject(value, "Non-Error thrown")), - "Agent run failed and failure reporting failed", - ); - throw new AgentHarnessError("unknown", cause.message, cause); - } - } - })(); - try { - const newMessages = await runResultPromise; - for (const message of newMessages.toReversed()) { - 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, - undefined, - this.runtime, - ); - 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, - runtime: this.runtime, - 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(toErrorObject(error, "Non-Error thrown")); - } - try { - await this.waitForIdle(); - } catch (error) { - errors.push(toErrorObject(error, "Non-Error thrown")); - } - try { - await this.emitOwn({ type: "abort", clearedSteer, clearedFollowUp }); - } catch (error) { - errors.push(toErrorObject(error, "Non-Error thrown")); - } - 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); - } -} - -export { CoreAgentHarness as AgentHarness }; - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/packages/agent-core/src/harness/compaction/branch-summarization.ts b/packages/agent-core/src/harness/compaction/branch-summarization.ts index 55438a2577a1..85fc82b3fcbd 100644 --- a/packages/agent-core/src/harness/compaction/branch-summarization.ts +++ b/packages/agent-core/src/harness/compaction/branch-summarization.ts @@ -1,5 +1,5 @@ // Agent Core module implements branch summarization behavior. -import type { Model, StreamFn } from "../../../../llm-core/src/index.js"; +import type { Model, StreamFn } from "@openclaw/llm-core"; import { type AgentCoreCompletionRuntimeDeps, resolveAgentCoreCompleteFn, @@ -12,7 +12,7 @@ import { createCompactionSummaryMessage, createCustomMessage, } from "../messages.js"; -import type { BranchSummaryResult, Session, SessionTreeEntry } from "../types.js"; +import type { BranchSummaryResult, SessionTreeEntry } from "../types.js"; import { BranchSummaryError, err, ok, type Result } from "../types.js"; import { estimateTokens, SUMMARIZATION_SYSTEM_PROMPT } from "./compaction.js"; import { @@ -42,14 +42,6 @@ export interface BranchPreparation { 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; -} - /** Minimal tree entry shape needed to compare two session branches. */ export interface BranchPathEntry { /** Stable entry id. */ @@ -109,19 +101,6 @@ export function collectEntriesForBranchSummaryFromBranches { - if (!oldLeafId) { - return { entries: [], commonAncestorId: null }; - } - const oldBranch = await session.getBranch(oldLeafId); - const targetPath = await session.getBranch(targetId); - return collectEntriesForBranchSummaryFromBranches(oldBranch, targetPath); -} function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined { switch (entry.type) { case "message": diff --git a/packages/agent-core/src/harness/compaction/compaction.ts b/packages/agent-core/src/harness/compaction/compaction.ts index 8a5f643c76e9..d5542c07699e 100644 --- a/packages/agent-core/src/harness/compaction/compaction.ts +++ b/packages/agent-core/src/harness/compaction/compaction.ts @@ -7,7 +7,7 @@ import { type SimpleStreamOptions, type StreamFn, type Usage, -} from "../../../../llm-core/src/index.js"; +} from "@openclaw/llm-core"; import { resolveAgentReasoningOption } from "../../reasoning.js"; import { type AgentCoreCompletionRuntimeDeps, diff --git a/packages/agent-core/src/harness/compaction/utils.test.ts b/packages/agent-core/src/harness/compaction/utils.test.ts index 18ed3bfbed3d..19996f01ad37 100644 --- a/packages/agent-core/src/harness/compaction/utils.test.ts +++ b/packages/agent-core/src/harness/compaction/utils.test.ts @@ -1,5 +1,5 @@ +import type { Message } from "@openclaw/llm-core"; import { describe, expect, it } from "vitest"; -import type { Message } from "../../../../llm-core/src/index.js"; import { serializeConversation } from "./utils.js"; describe("serializeConversation", () => { diff --git a/packages/agent-core/src/harness/compaction/utils.ts b/packages/agent-core/src/harness/compaction/utils.ts index 450d1e4cf9fa..ae98d21526d7 100644 --- a/packages/agent-core/src/harness/compaction/utils.ts +++ b/packages/agent-core/src/harness/compaction/utils.ts @@ -1,17 +1,10 @@ +import type { Message } from "@openclaw/llm-core"; // Agent Core helper module supports utils behavior. import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; -import type { Message } from "../../../../llm-core/src/index.js"; import type { AgentMessage } from "../../types.js"; +import type { FileOperations } 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; -} +export type { FileOperations } from "../types.js"; /** Create an empty file-operation accumulator. */ export function createFileOps(): FileOperations { diff --git a/packages/agent-core/src/harness/env/nodejs.test.ts b/packages/agent-core/src/harness/env/nodejs.test.ts deleted file mode 100644 index 220b6cac5608..000000000000 --- a/packages/agent-core/src/harness/env/nodejs.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -// Agent Core tests cover nodejs behavior. -import { EventEmitter } from "node:events"; -import { parse } from "node:path"; -import { PassThrough } from "node:stream"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { NodeExecutionEnv } from "./nodejs.js"; - -const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })); - -vi.mock("node:child_process", () => ({ - spawn: spawnMock, -})); - -afterEach(() => { - vi.unstubAllEnvs(); - vi.restoreAllMocks(); - vi.clearAllMocks(); -}); - -function mockSpawnChild() { - const child = Object.assign(new EventEmitter(), { - pid: 12345, - stdin: new PassThrough(), - stdout: new PassThrough(), - stderr: new PassThrough(), - kill: vi.fn(() => true), - }); - spawnMock.mockReturnValue(child); - return child as typeof child & { - stdin: PassThrough; - stdout: PassThrough; - stderr: PassThrough; - }; -} - -function createMockExecEnv(): NodeExecutionEnv { - return new NodeExecutionEnv({ cwd: process.cwd(), shellPath: process.execPath }); -} - -async function waitForSpawnCall(): Promise { - const deadline = Date.now() + 2_000; - while (Date.now() < deadline) { - if (spawnMock.mock.calls.length > 0) { - return; - } - await new Promise((resolve) => { - setImmediate(resolve); - }); - } - throw new Error("expected spawn to be called"); -} - -describe("NodeExecutionEnv file metadata", () => { - let env: NodeExecutionEnv; - let tempDir: string; - - beforeEach(async () => { - const rootEnv = new NodeExecutionEnv({ cwd: process.cwd() }); - const created = await rootEnv.createTempDir("agent-core-nodejs-"); - if (!created.ok) { - throw created.error; - } - tempDir = created.value; - env = new NodeExecutionEnv({ cwd: tempDir }); - }); - - afterEach(async () => { - const removed = await env.remove(tempDir, { recursive: true, force: true }); - if (!removed.ok) { - throw removed.error; - } - }); - - it("reports basenames consistently from fileInfo and listDir", async () => { - const written = await env.writeFile("notes/todo.txt", "hello"); - expect(written.ok).toBe(true); - - const info = await env.fileInfo("notes/todo.txt"); - expect(info.ok).toBe(true); - if (info.ok) { - expect(info.value.name).toBe("todo.txt"); - } - - const entries = await env.listDir("notes"); - expect(entries.ok).toBe(true); - if (entries.ok) { - expect(entries.value.map((entry) => entry.name)).toEqual(["todo.txt"]); - } - }); - - it("reports an empty basename for the filesystem root", async () => { - const info = await env.fileInfo(parse(tempDir).root); - expect(info.ok).toBe(true); - if (info.ok) { - expect(info.value.name).toBe(""); - } - }); - - it.runIf(process.platform !== "win32")("preserves backslashes in POSIX filenames", async () => { - const fileName = "notes\\todo.txt"; - const written = await env.writeFile(fileName, "hello"); - expect(written.ok).toBe(true); - - const info = await env.fileInfo(fileName); - expect(info.ok).toBe(true); - if (info.ok) { - expect(info.value.name).toBe(fileName); - } - }); -}); - -describe("NodeExecutionEnv timeout handling", () => { - let env: NodeExecutionEnv; - - beforeEach(() => { - env = createMockExecEnv(); - }); - - it.each([ - { timeout: 1, expectedDelayMs: 1_000 }, - { timeout: 1.5, expectedDelayMs: 1_500 }, - { timeout: 0.0005, expectedDelayMs: 1 }, - { timeout: Number.MAX_SAFE_INTEGER, expectedDelayMs: 2_147_000_000 }, - ])("schedules timeout $timeout as $expectedDelayMs ms", async ({ timeout, expectedDelayMs }) => { - const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); - const child = mockSpawnChild(); - - const resultPromise = env.exec("echo hello", { timeout }); - await waitForSpawnCall(); - - expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), expectedDelayMs); - child.emit("close", 0); - await expect(resultPromise).resolves.toMatchObject({ ok: true }); - }); - - it.each([undefined, Number.NaN, 0, -1])( - "does not schedule an invalid timeout value %s", - async (timeout) => { - const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); - const child = mockSpawnChild(); - - const resultPromise = env.exec("echo hello", { timeout }); - await waitForSpawnCall(); - - expect(timeoutSpy).not.toHaveBeenCalled(); - child.emit("close", 0); - await expect(resultPromise).resolves.toMatchObject({ ok: true }); - }, - ); -}); - -describe("NodeExecutionEnv exec stream errors", () => { - let env: NodeExecutionEnv; - - beforeEach(() => { - env = createMockExecEnv(); - }); - - it.each(["stdout", "stderr"] as const)( - "rejects with spawn_error when %s stream emits an error", - async (streamName) => { - const child = mockSpawnChild(); - - const resultPromise = env.exec("echo hello"); - await waitForSpawnCall(); - - child[streamName].emit("error", new Error(`${streamName} EPIPE`)); - - const result = await resultPromise; - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.code).toBe("spawn_error"); - expect(result.error.message).toContain(`${streamName} read error`); - expect(result.error.message).toContain("EPIPE"); - } - }, - ); - - it("keeps the other stream guarded after a stdout error", async () => { - const child = mockSpawnChild(); - - const resultPromise = env.exec("echo hello"); - await waitForSpawnCall(); - - child.stdout.emit("error", new Error("stdout EPIPE")); - - // stderr error after stdout already failed must not throw - expect(() => { - child.stderr.emit("error", new Error("stderr later")); - }).not.toThrow(); - - const result = await resultPromise; - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("stdout read error"); - } - }); - - it("completes normally when no stream errors occur", async () => { - const child = mockSpawnChild(); - - const resultPromise = env.exec("echo hello"); - await waitForSpawnCall(); - child.emit("close", 0); - - const result = await resultPromise; - expect(result.ok).toBe(true); - }); - - it("contains stdout errors during Windows shell discovery", async () => { - const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); - Object.defineProperty(process, "platform", { value: "win32", configurable: true }); - // Force PATH discovery even on Windows hosts with Git Bash in Program Files. - vi.stubEnv("ProgramFiles", ""); - vi.stubEnv("ProgramFiles(x86)", ""); - try { - const child = mockSpawnChild(); - const resultPromise = new NodeExecutionEnv({ cwd: process.cwd() }).exec("echo hello"); - await waitForSpawnCall(); - expect(spawnMock.mock.calls[0]?.[0]).toBe("where"); - expect(spawnMock.mock.calls[0]?.[1]).toEqual(["bash.exe"]); - - child.stdout.emit("error", new Error("where stdout failed")); - - const result = await resultPromise; - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.code).toBe("shell_unavailable"); - } - } finally { - if (platformDescriptor) { - Object.defineProperty(process, "platform", platformDescriptor); - } - } - }); -}); diff --git a/packages/agent-core/src/harness/env/nodejs.ts b/packages/agent-core/src/harness/env/nodejs.ts deleted file mode 100644 index 12a6b6f2d3f3..000000000000 --- a/packages/agent-core/src/harness/env/nodejs.ts +++ /dev/null @@ -1,651 +0,0 @@ -// Agent Core module implements nodejs behavior. -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 { basename, isAbsolute, join, resolve } from "node:path"; -import { createInterface } from "node:readline"; -import { toErrorObject } from "@openclaw/normalization-core/error-coercion"; -import { - type ExecutionEnv, - ExecutionError, - err, - FileError, - type FileInfo, - type FileKind, - ok, - type Result, -} from "../types.js"; -import { killProcessTree } from "./kill-tree.js"; - -const MAX_TIMER_TIMEOUT_MS = 2_147_000_000; - -function resolvePath(cwd: string, path: string): string { - return isAbsolute(path) ? path : resolve(cwd, path); -} - -/** Convert user-facing timeout seconds into a positive, timer-safe millisecond delay. */ -function resolveExecTimeoutMs(timeoutSeconds: unknown): number | undefined { - if ( - typeof timeoutSeconds !== "number" || - !Number.isFinite(timeoutSeconds) || - timeoutSeconds <= 0 - ) { - return undefined; - } - const milliseconds = Math.floor(timeoutSeconds * 1000); - if (!Number.isFinite(milliseconds) || milliseconds <= 0) { - return 1; - } - return Math.min(milliseconds, MAX_TIMER_TIMEOUT_MS); -} - -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: basename(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 = toErrorObject(error, "Non-Error thrown"); - 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; -} - -type ChildOutputStreamName = "stdout" | "stderr"; - -function listenForChildOutputErrors( - child: ReturnType, - onError: (stream: ChildOutputStreamName, error: Error) => void, -): void { - for (const streamName of ["stdout", "stderr"] as const) { - child[streamName]?.on("error", (error: Error) => onError(streamName, error)); - } -} - -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((resolveLocal) => { - let stdout = ""; - let child: ReturnType; - try { - child = spawn(command, args, { - stdio: ["ignore", "pipe", "ignore"], - windowsHide: true, - }); - } catch { - resolveLocal({ stdout: "", status: null }); - return; - } - const timeout = setTimeout(() => { - if (child.pid) { - killProcessTree(child.pid, { force: true, detached: false }); - } - }, timeoutMs); - child.stdout?.setEncoding("utf8"); - child.stdout?.on("data", (chunk: string) => { - stdout += chunk; - }); - listenForChildOutputErrors(child, () => { - if (child.pid) { - killProcessTree(child.pid, { force: true, detached: false }); - } - clearTimeout(timeout); - resolveLocal({ stdout: "", status: null }); - }); - child.on("error", () => { - clearTimeout(timeout); - resolveLocal({ stdout: "", status: null }); - }); - child.on("close", (status) => { - clearTimeout(timeout); - resolveLocal({ 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, - }; -} - -/** Node-backed execution environment for agent harness filesystem and shell operations. */ -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; - const timeoutRef: { current?: ReturnType } = {}; - - const onAbort = () => { - if (child?.pid) { - killProcessTree(child.pid, { force: true, detached: true }); - } - }; - - const settle = ( - result: Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>, - ) => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - 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 = toErrorObject(error, "Non-Error thrown"); - settle(err(new ExecutionError("spawn_error", cause.message, cause))); - return; - } - - const timeoutMs = resolveExecTimeoutMs(options?.timeout); - timeoutRef.current = - timeoutMs === undefined - ? undefined - : setTimeout(() => { - timedOut = true; - if (child?.pid) { - killProcessTree(child.pid, { force: true, detached: true }); - } - }, timeoutMs); - - 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 = toErrorObject(error, "Non-Error thrown"); - 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 = toErrorObject(error, "Non-Error thrown"); - callbackError = new ExecutionError("callback_error", cause.message, cause); - onAbort(); - } - }); - - // Guard stdout/stderr against stream errors (e.g. EPIPE when the - // child exits before all pipe data is consumed). Without listeners, - // Node.js throws an uncaught exception that crashes the process. - const onStreamError = (stream: ChildOutputStreamName, error: Error) => { - if (settled) { - return; - } - onAbort(); - settle( - err(new ExecutionError("spawn_error", `${stream} read error: ${error.message}`, error)), - ); - }; - listenForChildOutputErrors(child, onStreamError); - - 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 = "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 index 35f3abccd46c..2933c427ba5d 100644 --- a/packages/agent-core/src/harness/messages.ts +++ b/packages/agent-core/src/harness/messages.ts @@ -1,5 +1,5 @@ // Agent Core module implements messages behavior. -import type { ImageContent, Message, TextContent } from "../../../llm-core/src/index.js"; +import type { ImageContent, Message, TextContent } from "@openclaw/llm-core"; import type { AgentMessage, BashExecutionMessage, @@ -7,7 +7,6 @@ import type { CompactionSummaryMessage, CustomMessage, } from "../types.js"; -import { parseSessionTimestampMs, requireSessionTimestampMs } from "./session/timestamps.js"; export type { BashExecutionMessage, @@ -30,6 +29,22 @@ export function asAgentMessage(message: HarnessMessage): AgentMessage { return message as AgentMessage; } +function parseSessionTimestampMs(value: unknown): number | undefined { + if (typeof value !== "string" || !value.trim()) { + return undefined; + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function requireSessionTimestampMs(value: string, label: string): number { + const parsed = parseSessionTimestampMs(value); + if (parsed === undefined) { + throw new Error(`${label} must be a valid timestamp`); + } + return parsed; +} + function normalizeCompactionSummaryTimestamp(timestamp: number | string): number { if (typeof timestamp === "number") { return timestamp; diff --git a/packages/agent-core/src/harness/prompt-template-arguments.ts b/packages/agent-core/src/harness/prompt-template-arguments.ts index 76ed8baf34fd..1bf4b9de21df 100644 --- a/packages/agent-core/src/harness/prompt-template-arguments.ts +++ b/packages/agent-core/src/harness/prompt-template-arguments.ts @@ -1,4 +1,8 @@ -import type { PromptTemplate } from "./types.js"; +export interface PromptTemplate { + name: string; + description?: string; + content: string; +} /** Parse an argument string using simple shell-style single and double quotes. */ export function parseCommandArgs(argsString: string): string[] { diff --git a/packages/agent-core/src/harness/session/jsonl-storage.test.ts b/packages/agent-core/src/harness/session/jsonl-storage.test.ts deleted file mode 100644 index 4f638183269f..000000000000 --- a/packages/agent-core/src/harness/session/jsonl-storage.test.ts +++ /dev/null @@ -1,314 +0,0 @@ -// Agent Core tests cover jsonl storage behavior. -import { describe, expect, it } from "vitest"; -import { NodeExecutionEnv } from "../env/nodejs.js"; -import { ok, type FileSystem } from "../types.js"; -import { JsonlSessionStorage, loadJsonlSessionMetadata } from "./jsonl-storage.js"; -import { Session } from "./session.js"; - -type JsonlStorageFs = Pick< - FileSystem, - "readTextFile" | "readTextLines" | "writeFile" | "appendFile" ->; - -function createReadOnlyFs(content: string): JsonlStorageFs { - return { - readTextFile: async () => ok(content), - readTextLines: async (_path, options) => ok(content.split("\n").slice(0, options?.maxLines)), - writeFile: async () => ok(undefined), - appendFile: async () => ok(undefined), - }; -} - -describe("JsonlSessionStorage timestamps", () => { - it("rejects invalid session header timestamps", async () => { - const fs = createReadOnlyFs( - `${JSON.stringify({ - type: "session", - version: 3, - id: "session-1", - timestamp: "not-a-date", - cwd: "/repo", - })}\n`, - ); - - await expect(loadJsonlSessionMetadata(fs, "/sessions/invalid.jsonl")).rejects.toThrow( - "session header has invalid timestamp", - ); - }); - - it("rejects invalid entry timestamps", async () => { - const fs = createReadOnlyFs( - `${JSON.stringify({ - type: "session", - version: 3, - id: "session-1", - timestamp: "2026-01-01T00:00:00.000Z", - cwd: "/repo", - })}\n${JSON.stringify({ - type: "custom", - id: "entry-1", - parentId: null, - timestamp: "not-a-date", - customType: "note", - })}\n`, - ); - - await expect(JsonlSessionStorage.open(fs, "/sessions/invalid-entry.jsonl")).rejects.toThrow( - "line 2 has invalid timestamp", - ); - }); - - it("reports physical entry line numbers when blank JSONL rows are skipped", async () => { - const header = JSON.stringify({ - type: "session", - version: 3, - id: "session-1", - timestamp: "2026-01-01T00:00:00.000Z", - cwd: "/repo", - }); - const entry = JSON.stringify({ - type: "custom", - id: "entry-1", - parentId: null, - timestamp: "2026-01-01T00:00:01.000Z", - customType: "note", - }); - const invalidContent = [header, "\t\r", "", entry, " ", "not-json", ""].join("\n"); - const validContent = [header, "\t\r", "", entry, " ", ""].join("\n"); - const rootEnv = new NodeExecutionEnv({ cwd: process.cwd() }); - const created = await rootEnv.createTempDir("agent-core-jsonl-"); - if (!created.ok) { - throw created.error; - } - const fs = new NodeExecutionEnv({ cwd: created.value }); - - try { - const invalidWrite = await fs.writeFile("invalid-entry.jsonl", invalidContent); - if (!invalidWrite.ok) { - throw invalidWrite.error; - } - await expect(JsonlSessionStorage.open(fs, "invalid-entry.jsonl")).rejects.toMatchObject({ - name: "SessionError", - code: "invalid_entry", - message: "Invalid JSONL session file invalid-entry.jsonl: line 6 is not valid JSON", - }); - - const validWrite = await fs.writeFile("valid.jsonl", validContent); - if (!validWrite.ok) { - throw validWrite.error; - } - const storage = await JsonlSessionStorage.open(fs, "valid.jsonl"); - expect((await storage.getEntries()).map((storedEntry) => storedEntry.id)).toEqual([ - "entry-1", - ]); - } finally { - const removed = await rootEnv.remove(created.value, { recursive: true, force: true }); - expect(removed.ok).toBe(true); - } - }); - - it("uses a leaf control's opaque append parent for the next entry", async () => { - let content = [ - { - type: "session", - version: 3, - id: "session-1", - timestamp: "2026-06-15T00:00:00.000Z", - cwd: "/repo", - }, - { - type: "custom", - id: "active-root", - parentId: null, - timestamp: "2026-06-15T00:00:01.000Z", - customType: "root", - }, - { - type: "metadata", - id: "plugin-metadata", - parentId: null, - timestamp: "2026-06-15T00:00:02.000Z", - }, - { - type: "leaf", - id: "active-leaf", - parentId: "inactive-tail", - timestamp: "2026-06-15T00:00:03.000Z", - targetId: "active-root", - appendParentId: "plugin-metadata", - }, - ] - .map((entry) => JSON.stringify(entry)) - .join("\n"); - content += "\n"; - const fs: JsonlStorageFs = { - ...createReadOnlyFs(content), - readTextFile: async () => ok(content), - appendFile: async (_path, appended) => { - content += String(appended); - return ok(undefined); - }, - }; - const storage = await JsonlSessionStorage.open(fs, "/sessions/session.jsonl"); - const session = new Session(storage); - - expect(await session.getLeafId()).toBe("active-root"); - const entryId = await session.appendCustomEntry("continued"); - const entry = await session.getEntry(entryId); - - expect(entry).toMatchObject({ parentId: "plugin-metadata" }); - expect((await storage.getPathToRoot(entryId)).map((pathEntry) => pathEntry.id)).toEqual([ - "active-root", - entryId, - ]); - expect(content.trim().split(/\r?\n/).at(-1)).toContain('"parentId":"plugin-metadata"'); - }); - - it("keeps a terminal side append off the visible branch", async () => { - let content = [ - { - type: "session", - version: 3, - id: "session-1", - timestamp: "2026-06-15T00:00:00.000Z", - cwd: "/repo", - }, - { - type: "custom", - id: "active-root", - parentId: null, - timestamp: "2026-06-15T00:00:01.000Z", - customType: "active", - }, - { - type: "custom", - id: "side-one", - parentId: "active-root", - timestamp: "2026-06-15T00:00:02.000Z", - customType: "side", - }, - { - type: "leaf", - id: "side-leaf", - parentId: "side-one", - timestamp: "2026-06-15T00:00:03.000Z", - targetId: "active-root", - appendParentId: "side-one", - appendMode: "side", - }, - { - type: "custom", - id: "side-two", - parentId: "side-one", - timestamp: "2026-06-15T00:00:04.000Z", - customType: "side", - appendMode: "side", - }, - ] - .map((entry) => JSON.stringify(entry)) - .join("\n"); - content += "\n"; - const fs: JsonlStorageFs = { - ...createReadOnlyFs(content), - readTextFile: async () => ok(content), - appendFile: async (_path, appended) => { - content += String(appended); - return ok(undefined); - }, - }; - const storage = await JsonlSessionStorage.open(fs, "/sessions/session.jsonl"); - const session = new Session(storage); - - expect(await storage.getLeafId()).toBe("active-root"); - expect(await storage.getAppendParentId()).toBe("side-two"); - const entryId = await session.appendCustomEntry("continued"); - - expect(await storage.getEntry(entryId)).toMatchObject({ parentId: "side-two" }); - expect((await storage.getPathToRoot(entryId)).map((entry) => entry.id)).toEqual([ - "active-root", - entryId, - ]); - }); - - it("does not let opaque rows replace the selected visible leaf", async () => { - const content = [ - { - type: "session", - version: 3, - id: "session-1", - timestamp: "2026-06-15T00:00:00.000Z", - cwd: "/repo", - }, - { - type: "custom", - id: "active-root", - parentId: null, - timestamp: "2026-06-15T00:00:01.000Z", - customType: "active", - }, - { - type: "custom", - id: "inactive-root", - parentId: null, - timestamp: "2026-06-15T00:00:02.000Z", - customType: "inactive", - }, - { - type: "leaf", - id: "active-leaf", - parentId: "inactive-root", - timestamp: "2026-06-15T00:00:03.000Z", - targetId: "active-root", - }, - { - type: "metadata", - id: "plugin-metadata", - parentId: "inactive-root", - timestamp: "2026-06-15T00:00:04.000Z", - }, - ] - .map((entry) => JSON.stringify(entry)) - .join("\n"); - const storage = await JsonlSessionStorage.open( - createReadOnlyFs(`${content}\n`), - "/sessions/session.jsonl", - ); - const session = new Session(storage); - - expect(await session.getLeafId()).toBe("active-root"); - expect((await session.getBranch()).map((entry) => entry.id)).toEqual(["active-root"]); - }); - - it("rejects a leaf control with a missing append parent", async () => { - const content = [ - { - type: "session", - version: 3, - id: "session-1", - timestamp: "2026-06-15T00:00:00.000Z", - cwd: "/repo", - }, - { - type: "custom", - id: "active-root", - parentId: null, - timestamp: "2026-06-15T00:00:01.000Z", - customType: "active", - }, - { - type: "leaf", - id: "active-leaf", - parentId: "active-root", - timestamp: "2026-06-15T00:00:02.000Z", - targetId: "active-root", - appendParentId: "missing", - }, - ] - .map((entry) => JSON.stringify(entry)) - .join("\n"); - - await expect( - JsonlSessionStorage.open(createReadOnlyFs(`${content}\n`), "/sessions/session.jsonl"), - ).rejects.toThrow("Append parent missing not found"); - }); -}); diff --git a/packages/agent-core/src/harness/session/jsonl-storage.ts b/packages/agent-core/src/harness/session/jsonl-storage.ts deleted file mode 100644 index eef111dd7e1f..000000000000 --- a/packages/agent-core/src/harness/session/jsonl-storage.ts +++ /dev/null @@ -1,303 +0,0 @@ -// Agent Core module implements jsonl storage behavior. -import { toErrorObject } from "@openclaw/normalization-core/error-coercion"; -import type { - FileError, - FileSystem, - JsonlSessionMetadata, - Result, - SessionTreeEntry, -} from "../types.js"; -import { SessionError } from "../types.js"; -import { - appendParentIdAfterEntry, - BaseSessionStorage, - leafIdUpdateAfterEntry, -} from "./storage-base.js"; -import { parseSessionTimestampMs } from "./timestamps.js"; - -type JsonlSessionStorageFileSystem = Pick< - FileSystem, - "readTextFile" | "readTextLines" | "writeFile" | "appendFile" ->; - -interface SessionHeader { - type: "session"; - version: 3; - id: string; - timestamp: string; - cwd: string; - parentSession?: string; -} - -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; -} - -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", - toErrorObject(error, "Non-Error thrown"), - ); - } - 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 (parseSessionTimestampMs(parsed.timestamp) === undefined) { - throw invalidSession(filePath, "session header has invalid 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", - toErrorObject(error, "Non-Error thrown"), - ); - } - 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 (parseSessionTimestampMs(parsed.timestamp) === undefined) { - throw invalidEntry(filePath, lineNumber, "has invalid timestamp"); - } - if (parsed.type === "leaf" && parsed.targetId !== null && typeof parsed.targetId !== "string") { - throw invalidEntry(filePath, lineNumber, "has invalid targetId"); - } - if ( - parsed.type === "leaf" && - parsed.appendParentId !== undefined && - parsed.appendParentId !== null && - typeof parsed.appendParentId !== "string" - ) { - throw invalidEntry(filePath, lineNumber, "has invalid appendParentId"); - } - if (parsed.appendMode !== undefined && parsed.appendMode !== "side") { - throw invalidEntry(filePath, lineNumber, "has invalid appendMode"); - } - return parsed as unknown as SessionTreeEntry; -} - -function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSessionMetadata { - return { - id: header.id, - createdAt: header.timestamp, - cwd: header.cwd, - path, - parentSessionPath: header.parentSession, - }; -} - -/** Read only the JSONL session header and convert it to session metadata. */ -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; - appendParentId: string | null; -}> { - const content = getFileSystemResultOrThrow( - await fs.readTextFile(filePath), - `Failed to read session ${filePath}`, - ); - const lines = content.split("\n"); - const headerIndex = lines.findIndex((line) => line.trim()); - if (headerIndex === -1) { - throw invalidSession(filePath, "missing session header"); - } - - const headerLine = lines.at(headerIndex); - if (headerLine === undefined) { - throw invalidSession(filePath, "missing session header"); - } - const header = parseHeaderLine(headerLine, filePath); - const entries: SessionTreeEntry[] = []; - let leafId: string | null = null; - let appendParentId: string | null = null; - for (const [offset, line] of lines.slice(headerIndex + 1).entries()) { - if (!line.trim()) { - continue; - } - const entry = parseEntryLine(line, filePath, headerIndex + offset + 2); - entries.push(entry); - const leafUpdate = leafIdUpdateAfterEntry(entry); - if (leafUpdate !== undefined) { - leafId = leafUpdate; - } - appendParentId = appendParentIdAfterEntry(entry); - } - return { header, entries, leafId, appendParentId }; -} - -/** Append-only JSONL-backed storage for one session tree. */ -export class JsonlSessionStorage extends BaseSessionStorage { - private readonly fs: JsonlSessionStorageFileSystem; - private readonly filePath: string; - - private constructor( - fs: JsonlSessionStorageFileSystem, - filePath: string, - header: SessionHeader, - entries: SessionTreeEntry[], - leafId: string | null, - appendParentId: string | null, - ) { - super(headerToSessionMetadata(header, filePath), entries, leafId, appendParentId); - this.fs = fs; - this.filePath = filePath; - } - - 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, - loaded.appendParentId, - ); - } - - /** Create a new JSONL file with a session header and no entries. */ - 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, null); - } - - override async setLeafId(leafId: string | null): Promise { - const entry = this.createLeafEntry(leafId); - getFileSystemResultOrThrow( - await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`), - `Failed to append session leaf ${entry.id}`, - ); - this.recordEntry(entry); - } - - override async appendEntry(entry: SessionTreeEntry): Promise { - this.validateEntryForAppend(entry); - getFileSystemResultOrThrow( - await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`), - `Failed to append session entry ${entry.id}`, - ); - this.recordEntry(entry); - } -} diff --git a/packages/agent-core/src/harness/session/memory-storage.test.ts b/packages/agent-core/src/harness/session/memory-storage.test.ts deleted file mode 100644 index 7d8037ab2672..000000000000 --- a/packages/agent-core/src/harness/session/memory-storage.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -// Agent Core tests cover memory storage behavior. -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { SessionTreeEntry } from "../types.js"; -import { InMemorySessionStorage } from "./memory-storage.js"; -import { Session } from "./session.js"; - -const rootEntry: SessionTreeEntry = { - type: "custom", - id: "root", - parentId: null, - timestamp: "2026-01-01T00:00:00.000Z", - customType: "root", -}; - -const childEntry: SessionTreeEntry = { - type: "custom", - id: "child", - parentId: "root", - timestamp: "2026-01-01T00:00:01.000Z", - customType: "child", -}; - -afterEach(() => { - vi.useRealTimers(); - vi.unstubAllGlobals(); -}); - -describe("InMemorySessionStorage", () => { - it.each([32, 128])("keeps %i rapid short entry ids unique", async (count) => { - let randomValue = 0; - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); - vi.stubGlobal("crypto", { - getRandomValues(bytes: Uint8Array) { - bytes.fill(0); - new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setUint32( - bytes.byteLength - 4, - randomValue++, - ); - return bytes; - }, - }); - const storage = new InMemorySessionStorage(); - - const ids = await Promise.all(Array.from({ length: count }, () => storage.createEntryId())); - - expect(ids.every((id) => id.length === 8)).toBe(true); - expect(new Set(ids).size).toBe(count); - }); - - it("uses shared entry indexes for labels, leaves, and paths", async () => { - const storage = new InMemorySessionStorage({ - entries: [ - rootEntry, - childEntry, - { - type: "label", - id: "label-1", - parentId: "child", - timestamp: "2026-01-01T00:00:02.000Z", - targetId: "child", - label: " latest ", - }, - ], - }); - - expect(await storage.getLeafId()).toBe("label-1"); - expect(await storage.getLabel("child")).toBe("latest"); - expect((await storage.getPathToRoot("child")).map((entry) => entry.id)).toEqual([ - "root", - "child", - ]); - }); - - it("records explicit leaf updates through the shared storage path", async () => { - const storage = new InMemorySessionStorage({ - entries: [rootEntry, childEntry], - }); - - await storage.setLeafId("root"); - - const entries = await storage.getEntries(); - const leaf = entries.at(-1); - expect(await storage.getLeafId()).toBe("root"); - expect(leaf).toMatchObject({ - type: "leaf", - parentId: "child", - targetId: "root", - }); - }); - - it("returns a selected branch in root-to-leaf order", async () => { - const activeLeaf: SessionTreeEntry = { - type: "custom", - id: "active-leaf", - parentId: "child", - timestamp: "2026-01-01T00:00:02.000Z", - customType: "active", - }; - const sideLeaf: SessionTreeEntry = { - type: "custom", - id: "side-leaf", - parentId: "root", - timestamp: "2026-01-01T00:00:03.000Z", - customType: "side", - }; - const storage = new InMemorySessionStorage({ - entries: [rootEntry, childEntry, activeLeaf, sideLeaf], - }); - - expect((await storage.getPathToRoot(activeLeaf.id)).map((entry) => entry.id)).toEqual([ - "root", - "child", - "active-leaf", - ]); - expect((await storage.getPathToRoot(sideLeaf.id)).map((entry) => entry.id)).toEqual([ - "root", - "side-leaf", - ]); - }); - - it("normalizes session names to one line", async () => { - const session = new Session(new InMemorySessionStorage()); - - await session.appendSessionName(" first\nsecond\r\nthird "); - - expect(await session.getSessionName()).toBe("first second third"); - }); - - it("traverses descendants of leaf markers through the selected target", async () => { - const leafEntry: SessionTreeEntry = { - type: "leaf", - id: "leaf-1", - parentId: "child", - timestamp: "2026-01-01T00:00:02.000Z", - targetId: "root", - }; - const replacementEntry: SessionTreeEntry = { - type: "custom", - id: "replacement", - parentId: leafEntry.id, - timestamp: "2026-01-01T00:00:03.000Z", - customType: "replacement", - }; - const storage = new InMemorySessionStorage({ - entries: [rootEntry, childEntry, leafEntry, replacementEntry], - }); - - expect((await storage.getPathToRoot(replacementEntry.id)).map((entry) => entry.id)).toEqual([ - "root", - "replacement", - ]); - expect((await storage.getPathToRoot(leafEntry.id)).map((entry) => entry.id)).toEqual(["root"]); - }); - - it("honors an explicit root append parent after a visible leaf selection", async () => { - const storage = new InMemorySessionStorage({ - entries: [ - rootEntry, - { - type: "leaf", - id: "leaf-1", - parentId: "root", - timestamp: "2026-01-01T00:00:01.000Z", - targetId: "root", - appendParentId: null, - }, - ], - }); - const session = new Session(storage); - - const entryId = await session.appendCustomEntry("new-root"); - - expect(await session.getEntry(entryId)).toMatchObject({ parentId: null }); - expect((await storage.getPathToRoot(entryId)).map((entry) => entry.id)).toEqual([ - "root", - entryId, - ]); - }); - - it("keeps marked side ancestry separate from the next active append", async () => { - const sideOne: SessionTreeEntry = { - type: "custom", - id: "side-one", - parentId: "root", - timestamp: "2026-01-01T00:00:01.000Z", - customType: "side", - }; - const sideTwo: SessionTreeEntry = { - type: "custom", - id: "side-two", - parentId: sideOne.id, - timestamp: "2026-01-01T00:00:03.000Z", - appendMode: "side", - customType: "side", - }; - const storage = new InMemorySessionStorage({ - entries: [ - rootEntry, - sideOne, - { - type: "leaf", - id: "first-leaf", - parentId: sideOne.id, - timestamp: "2026-01-01T00:00:02.000Z", - targetId: "root", - appendParentId: sideOne.id, - appendMode: "side", - }, - sideTwo, - ], - }); - const session = new Session(storage); - - expect(await storage.getLeafId()).toBe("root"); - expect(await storage.getAppendParentId()).toBe(sideTwo.id); - expect((await storage.getPathToRoot(sideTwo.id)).map((entry) => entry.id)).toEqual([ - "root", - sideOne.id, - sideTwo.id, - ]); - - const nextEntryId = await session.appendCustomEntry("active"); - expect((await storage.getPathToRoot(nextEntryId)).map((entry) => entry.id)).toEqual([ - "root", - nextEntryId, - ]); - }); - - it("rejects a leaf entry with a missing append parent before recording it", async () => { - const storage = new InMemorySessionStorage({ entries: [rootEntry] }); - - await expect( - storage.appendEntry({ - type: "leaf", - id: "leaf-1", - parentId: "root", - timestamp: "2026-01-01T00:00:01.000Z", - targetId: "root", - appendParentId: "missing", - }), - ).rejects.toThrow("Append parent missing not found"); - expect(await storage.getEntries()).toEqual([rootEntry]); - }); -}); diff --git a/packages/agent-core/src/harness/session/memory-storage.ts b/packages/agent-core/src/harness/session/memory-storage.ts deleted file mode 100644 index f8bfc32084f9..000000000000 --- a/packages/agent-core/src/harness/session/memory-storage.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Agent Core module implements memory storage behavior. -import type { SessionMetadata, SessionTreeEntry } from "../types.js"; -import { BaseSessionStorage } from "./storage-base.js"; -import { uuidv7 } from "./uuid.js"; - -/** Volatile session storage used by tests and in-process harness callers. */ -export class InMemorySessionStorage< - TMetadata extends SessionMetadata = SessionMetadata, -> extends BaseSessionStorage { - constructor(options?: { entries?: SessionTreeEntry[]; metadata?: TMetadata }) { - super( - options?.metadata ?? ({ id: uuidv7(), createdAt: new Date().toISOString() } as TMetadata), - options?.entries ? [...options.entries] : [], - ); - } - - override async setLeafId(leafId: string | null): Promise { - this.recordEntry(this.createLeafEntry(leafId)); - } - - override async appendEntry(entry: SessionTreeEntry): Promise { - this.recordEntry(entry); - } -} diff --git a/packages/agent-core/src/harness/session/session-context.test.ts b/packages/agent-core/src/harness/session/session-context.test.ts new file mode 100644 index 000000000000..32bdbbde48bb --- /dev/null +++ b/packages/agent-core/src/harness/session/session-context.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import type { SessionTreeEntry } from "../types.js"; +import { buildSessionContext } from "./session.js"; + +const timestamp = "2026-07-17T00:00:00.000Z"; + +function userEntry(id: string, parentId: string | null, content: string): SessionTreeEntry { + return { + type: "message", + id, + parentId, + timestamp, + message: { role: "user", content, timestamp: Date.parse(timestamp) }, + }; +} + +describe("buildSessionContext", () => { + it("replays only the retained tail and newer entries after compaction", () => { + const entries: SessionTreeEntry[] = [ + userEntry("old", null, "discarded"), + userEntry("kept", "old", "retained"), + { + type: "model_change", + id: "model", + parentId: "kept", + timestamp, + provider: "test-provider", + modelId: "test-model", + }, + { + type: "compaction", + id: "compaction", + parentId: "model", + timestamp, + summary: "older context", + firstKeptEntryId: "kept", + tokensBefore: 123, + }, + userEntry("new", "compaction", "new turn"), + ]; + + const context = buildSessionContext(entries); + + expect(context).toMatchObject({ + thinkingLevel: "off", + model: { provider: "test-provider", modelId: "test-model" }, + }); + expect(context.messages.map((message) => message.role)).toEqual([ + "compactionSummary", + "user", + "user", + ]); + expect(context.messages).toMatchObject([ + { summary: "older context" }, + { content: "retained" }, + { content: "new turn" }, + ]); + }); +}); diff --git a/packages/agent-core/src/harness/session/session.ts b/packages/agent-core/src/harness/session/session.ts index a155d459784b..0f95ab30d608 100644 --- a/packages/agent-core/src/harness/session/session.ts +++ b/packages/agent-core/src/harness/session/session.ts @@ -1,5 +1,3 @@ -// Agent Core module implements session behavior. -import type { ImageContent, TextContent } from "../../../../llm-core/src/index.js"; import type { AgentMessage } from "../../types.js"; import { asAgentMessage, @@ -7,24 +5,9 @@ import { 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"; +import type { CompactionEntry, SessionContext, SessionTreeEntry } from "../types.js"; -/** Build model context from the active session branch and its latest state markers. */ +/** Build model context from an ordered session branch and its latest state markers. */ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext { let thinkingLevel = "off"; let model: { provider: string; modelId: string } | null = null; @@ -76,10 +59,10 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon ), ); const compactionIdx = pathEntries.findIndex( - (e) => e.type === "compaction" && e.id === compaction.id, + (entry) => entry.type === "compaction" && entry.id === compaction.id, ); - // Replay only the compacted entry's retained tail plus newer branch entries; older - // transcript content is represented by the synthetic compaction summary above. + // The synthetic summary replaces only history before the retained tail; newer branch + // entries must still replay or post-compaction turns disappear from model context. let foundFirstKept = false; for (const entry of pathEntries.slice(0, compactionIdx)) { if (entry.id === compaction.firstKeptEntryId) { @@ -100,190 +83,3 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon return { messages, thinkingLevel, model }; } - -/** High-level session API backed by pluggable tree storage. */ -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(); - } - - private getAppendParentId(): Promise { - return this.storage.getAppendParentId?.() ?? 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.getAppendParentId(), - 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.getAppendParentId(), - 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.getAppendParentId(), - 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.getAppendParentId(), - timestamp: new Date().toISOString(), - summary, - firstKeptEntryId, - tokensBefore, - details, - fromHook, - } satisfies CompactionEntry); - } - - /** Append a non-LLM transcript marker for harness-specific state. */ - async appendCustomEntry(customType: string, data?: unknown): Promise { - return this.appendTypedEntry({ - type: "custom", - id: await this.storage.createEntryId(), - parentId: await this.getAppendParentId(), - timestamp: new Date().toISOString(), - customType, - data, - } satisfies CustomEntry); - } - - /** Append harness-specific content that can also be replayed into model context. */ - 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.getAppendParentId(), - timestamp: new Date().toISOString(), - customType, - content, - display, - details, - } satisfies CustomMessageEntry); - } - - /** Record or clear the display label for an existing session entry. */ - 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.getAppendParentId(), - 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.getAppendParentId(), - timestamp: new Date().toISOString(), - name: name.replace(/[\r\n]+/g, " ").trim(), - } satisfies SessionInfoEntry); - } - - /** Move the visible branch leaf and optionally attach a summary of the abandoned branch. */ - 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/storage-base.ts b/packages/agent-core/src/harness/session/storage-base.ts deleted file mode 100644 index 1da640dbd36b..000000000000 --- a/packages/agent-core/src/harness/session/storage-base.ts +++ /dev/null @@ -1,284 +0,0 @@ -// Agent Core module implements storage base behavior. -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 isSideAppendEntry(entry: SessionTreeEntry): boolean { - return entry.appendMode === "side"; -} - -function generateEntryId(byId: { has(id: string): boolean }): string { - for (let i = 0; i < 100; i++) { - const id = uuidv7().slice(-8); - if (!byId.has(id)) { - return id; - } - } - return uuidv7(); -} - -/** Return the visible-leaf update represented by one session tree entry. */ -export function leafIdUpdateAfterEntry(entry: SessionTreeEntry): string | null | undefined { - if (entry.type !== "leaf" && isSideAppendEntry(entry)) { - return undefined; - } - switch (entry.type) { - case "leaf": - return entry.targetId; - case "message": - case "thinking_level_change": - case "model_change": - case "compaction": - case "branch_summary": - case "custom": - case "custom_message": - case "label": - case "session_info": - return entry.id; - default: - // JSONL transcripts may contain parent-linked plugin rows that advance - // the raw append cursor without selecting a model-visible branch. - return undefined; - } -} - -/** Return the raw parent for the next append after applying a tree entry. */ -export function appendParentIdAfterEntry(entry: SessionTreeEntry): string | null { - return entry.type === "leaf" - ? entry.appendParentId === undefined - ? entry.targetId - : entry.appendParentId - : entry.id; -} - -function resolveLeafId(entries: readonly SessionTreeEntry[]): string | null { - let leafId: string | null = null; - for (const entry of entries) { - const update = leafIdUpdateAfterEntry(entry); - if (update !== undefined) { - leafId = update; - } - } - return leafId; -} - -function resolveAppendParentId(entries: readonly SessionTreeEntry[]): string | null { - let appendParentId: string | null = null; - for (const entry of entries) { - appendParentId = appendParentIdAfterEntry(entry); - } - return appendParentId; -} - -function buildLogicalParentsById(entries: readonly SessionTreeEntry[]): Map { - const logicalParentsById = new Map(); - let leafId: string | null = null; - let appendParentId: string | null = null; - for (const entry of entries) { - const leafUpdate = leafIdUpdateAfterEntry(entry); - if ( - leafUpdate === entry.id && - !isSideAppendEntry(entry) && - entry.parentId === appendParentId && - leafId !== appendParentId - ) { - logicalParentsById.set(entry.id, leafId); - } - if (leafUpdate !== undefined) { - leafId = leafUpdate; - } - appendParentId = appendParentIdAfterEntry(entry); - } - return logicalParentsById; -} - -export abstract class BaseSessionStorage< - TMetadata extends SessionMetadata = SessionMetadata, -> implements SessionStorage { - private readonly metadata: TMetadata; - private readonly entries: SessionTreeEntry[]; - private readonly byId: Map; - private readonly labelsById: Map; - private readonly logicalParentsById: Map; - private leafId: string | null; - private appendParentId: string | null; - - protected constructor( - metadata: TMetadata, - entries: SessionTreeEntry[], - leafId: string | null = resolveLeafId(entries), - appendParentId: string | null = resolveAppendParentId(entries), - ) { - this.metadata = metadata; - this.entries = entries; - this.byId = new Map(entries.map((entry) => [entry.id, entry])); - this.labelsById = buildLabelsById(entries); - this.logicalParentsById = buildLogicalParentsById(entries); - this.leafId = leafId; - this.appendParentId = appendParentId; - if (this.leafId !== null && !this.byId.has(this.leafId)) { - throw new SessionError("invalid_session", `Entry ${this.leafId} not found`); - } - if (this.appendParentId !== null && !this.byId.has(this.appendParentId)) { - throw new SessionError("invalid_session", `Append parent ${this.appendParentId} not found`); - } - } - - 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 getAppendParentId(): Promise { - if (this.appendParentId !== null && !this.byId.has(this.appendParentId)) { - throw new SessionError("invalid_session", `Append parent ${this.appendParentId} not found`); - } - return this.appendParentId; - } - - protected createLeafEntry(leafId: string | null): LeafEntry { - if (leafId !== null && !this.byId.has(leafId)) { - throw new SessionError("not_found", `Entry ${leafId} not found`); - } - return { - type: "leaf", - id: generateEntryId(this.byId), - parentId: this.appendParentId, - timestamp: new Date().toISOString(), - targetId: leafId, - }; - } - - async createEntryId(): Promise { - return generateEntryId(this.byId); - } - - protected validateEntryForAppend(entry: SessionTreeEntry): void { - const leafId = leafIdUpdateAfterEntry(entry); - const leafIsNewEntry = entry.type !== "leaf" && leafId === entry.id; - if (leafId !== undefined && leafId !== null && !leafIsNewEntry && !this.byId.has(leafId)) { - throw new SessionError("not_found", `Entry ${leafId} not found`); - } - - const appendParentId = appendParentIdAfterEntry(entry); - const appendParentIsNewEntry = entry.type !== "leaf" && appendParentId === entry.id; - if (appendParentId !== null && !appendParentIsNewEntry && !this.byId.has(appendParentId)) { - throw new SessionError("not_found", `Append parent ${appendParentId} not found`); - } - } - - protected recordEntry(entry: SessionTreeEntry): void { - // Leaf and label entries are append-only state changes; keep derived indexes - // synchronized here so memory and JSONL storage expose identical behavior. - this.validateEntryForAppend(entry); - const leafId = leafIdUpdateAfterEntry(entry); - if ( - leafId === entry.id && - !isSideAppendEntry(entry) && - entry.parentId === this.appendParentId && - this.leafId !== this.appendParentId - ) { - this.logicalParentsById.set(entry.id, this.leafId); - } - this.entries.push(entry); - this.byId.set(entry.id, entry); - updateLabelCache(this.labelsById, entry); - if (leafId !== undefined) { - this.leafId = leafId; - } - this.appendParentId = appendParentIdAfterEntry(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`); - } - const seen = new Set(); - while (current) { - if (seen.has(current.id)) { - throw new SessionError("invalid_session", `Cycle found at entry ${current.id}`); - } - seen.add(current.id); - if (current.type !== "leaf") { - path.push(current); - } - // Leaf rows are control records. Descendants written by older appenders - // may point at the marker, but their visible ancestry starts at its target. - const parentId = - current.type === "leaf" - ? current.targetId - : this.logicalParentsById.has(current.id) - ? (this.logicalParentsById.get(current.id) ?? null) - : current.parentId; - if (!parentId) { - break; - } - const parent = this.byId.get(parentId); - if (!parent) { - throw new SessionError("invalid_session", `Entry ${parentId} not found`); - } - current = parent; - } - path.reverse(); - return path; - } - - async getEntries(): Promise { - return [...this.entries]; - } - - abstract setLeafId(leafId: string | null): Promise; - abstract appendEntry(entry: SessionTreeEntry): Promise; -} diff --git a/packages/agent-core/src/harness/session/timestamps.ts b/packages/agent-core/src/harness/session/timestamps.ts deleted file mode 100644 index daf260845312..000000000000 --- a/packages/agent-core/src/harness/session/timestamps.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** Parse an ISO-like session timestamp to milliseconds. */ -export function parseSessionTimestampMs(value: unknown): number | undefined { - if (typeof value !== "string" || !value.trim()) { - return undefined; - } - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : undefined; -} - -/** Parse a required timestamp or throw a labeled validation error. */ -export function requireSessionTimestampMs(value: string, label: string): number { - const parsed = parseSessionTimestampMs(value); - if (parsed === undefined) { - throw new Error(`${label} must be a valid timestamp`); - } - return parsed; -} diff --git a/packages/agent-core/src/harness/session/uuid.ts b/packages/agent-core/src/harness/session/uuid.ts index 9dc50373a791..27b47ac2073b 100644 --- a/packages/agent-core/src/harness/session/uuid.ts +++ b/packages/agent-core/src/harness/session/uuid.ts @@ -1,8 +1,6 @@ -// Agent Core module implements uuid behavior. let lastTimestamp = -Infinity; let sequence = 0; -// Small UUIDv7 generator for browser/node package builds without a runtime dep. function fillRandomBytes(bytes: Uint8Array): void { const crypto = globalThis.crypto; if (crypto?.getRandomValues) { @@ -24,8 +22,6 @@ export function uuidv7(): string { sequence = new DataView(random.buffer, random.byteOffset + 6, 4).getUint32(0); lastTimestamp = timestamp; } else { - // Same-ms calls increment the sequence so generated ids remain sortable and - // unique even when random bytes repeat. sequence = (sequence + 1) >>> 0; if (sequence === 0) { lastTimestamp++; diff --git a/packages/agent-core/src/harness/skills.ts b/packages/agent-core/src/harness/skills.ts deleted file mode 100644 index 50cd5b39d17d..000000000000 --- a/packages/agent-core/src/harness/skills.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Agent Core module implements skill invocation formatting. -import type { Skill } from "./types.js"; - -/** 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; -} - -function dirnameEnvPath(path: string): string { - const normalized = path.replace(/\/+$/, ""); - const slashIndex = normalized.lastIndexOf("/"); - return slashIndex <= 0 ? "/" : normalized.slice(0, slashIndex); -} diff --git a/packages/agent-core/src/harness/types.test.ts b/packages/agent-core/src/harness/types.test.ts deleted file mode 100644 index 13d22894cf6b..000000000000 --- a/packages/agent-core/src/harness/types.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { toError } from "./types.js"; - -describe("toError", () => { - it("preserves the shipped facade semantics", () => { - const thrown = { code: "E_TEST", detail: "structured" }; - - const error = toError(thrown); - - expect(error.message).toBe('{"code":"E_TEST","detail":"structured"}'); - expect(error).not.toHaveProperty("cause"); - }); -}); diff --git a/packages/agent-core/src/harness/types.ts b/packages/agent-core/src/harness/types.ts index 8317ed09743f..823c3aa948b9 100644 --- a/packages/agent-core/src/harness/types.ts +++ b/packages/agent-core/src/harness/types.ts @@ -1,169 +1,12 @@ -// Agent Core type module defines shared TypeScript contracts. -import type { Result } from "@openclaw/normalization-core/result"; -import type { - ImageContent, - Model, - SimpleStreamOptions, - StreamFn, - TextContent, - Transport, -} from "../../../llm-core/src/index.js"; -import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../index.js"; -import type { AgentCoreCompletionRuntimeDeps, AgentCoreRuntimeDeps } from "../runtime-deps.js"; -import type { Session } from "./session/session.js"; +import type { ImageContent, TextContent } from "@openclaw/llm-core"; +import type { AgentMessage } from "../types.js"; export { err, ok } from "@openclaw/normalization-core/result"; export type { Result } from "@openclaw/normalization-core/result"; -/** - * @deprecated Use `toErrorObject` from `@openclaw/normalization-core/error-coercion`. - * Kept through the next major release for the shipped agent-core plugin API. - */ -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)); - } -} +type CompactionErrorCode = "aborted" | "summarization_failed" | "invalid_session" | "unknown"; -/** - * Skill loaded from a `SKILL.md` file or provided by an application. - * - * `name`, `description`, `filePath`, and optional `promptVersion` are available to host-owned prompt builders and - * direct skill invocation. - */ -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; - /** Deterministic marker for the skill content, rendered as when available. */ - promptVersion?: 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) { @@ -173,12 +16,9 @@ export class CompactionError extends Error { } } -/** Stable branch-summary error codes returned by branch summarization helpers. */ -export type BranchSummaryErrorCode = "aborted" | "summarization_failed" | "invalid_session"; +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) { @@ -188,195 +28,30 @@ export class BranchSummaryError extends Error { } } -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 {} - -/** Base fields shared by append-only session tree entries. */ -export interface SessionTreeEntryBase { - /** Entry discriminator used for JSONL persistence and typed narrowing. */ +interface SessionTreeEntryBase { type: string; - /** Stable entry id unique within a session file. */ id: string; - /** Parent entry id, or null for a root entry. */ parentId: string | null; - /** ISO timestamp string used for persistence and sorting. */ timestamp: string; - /** This row consumes the raw side cursor instead of the visible leaf. */ appendMode?: "side"; } -/** Persisted transcript message entry. */ -export interface MessageEntry extends SessionTreeEntryBase { +interface MessageEntry extends SessionTreeEntryBase { type: "message"; message: AgentMessage; } -/** Persisted thinking-level selection marker. */ -export interface ThinkingLevelChangeEntry extends SessionTreeEntryBase { +interface ThinkingLevelChangeEntry extends SessionTreeEntryBase { type: "thinking_level_change"; thinkingLevel: string; } -/** Persisted model selection marker. */ -export interface ModelChangeEntry extends SessionTreeEntryBase { +interface ModelChangeEntry extends SessionTreeEntryBase { type: "model_change"; provider: string; modelId: string; } -/** Persisted summary that replaces older transcript history in context. */ export interface CompactionEntry extends SessionTreeEntryBase { type: "compaction"; summary: string; @@ -386,8 +61,7 @@ export interface CompactionEntry extends SessionTreeEntryBase { fromHook?: boolean; } -/** Persisted summary of an abandoned branch when navigating the session tree. */ -export interface BranchSummaryEntry extends SessionTreeEntryBase { +interface BranchSummaryEntry extends SessionTreeEntryBase { type: "branch_summary"; fromId: string; summary: string; @@ -395,15 +69,13 @@ export interface BranchSummaryEntry extends SessionTreeEntryBase { fromHook?: boolean; } -/** Persisted harness/application marker that is not replayed into model context. */ -export interface CustomEntry extends SessionTreeEntryBase { +interface CustomEntry extends SessionTreeEntryBase { type: "custom"; customType: string; data?: T; } -/** Persisted harness/application message that can be replayed into model context. */ -export interface CustomMessageEntry extends SessionTreeEntryBase { +interface CustomMessageEntry extends SessionTreeEntryBase { type: "custom_message"; customType: string; content: string | (TextContent | ImageContent)[]; @@ -411,29 +83,23 @@ export interface CustomMessageEntry extends SessionTreeEntryBase { display: boolean; } -/** Append-only label update for another session entry. */ -export interface LabelEntry extends SessionTreeEntryBase { +interface LabelEntry extends SessionTreeEntryBase { type: "label"; targetId: string; label: string | undefined; } -/** Persisted session metadata marker. */ -export interface SessionInfoEntry extends SessionTreeEntryBase { - // The persisted discriminator predates the public "session name" wording. +interface SessionInfoEntry extends SessionTreeEntryBase { type: "session_info"; name?: string; } -/** Append-only marker that changes the active visible leaf. */ -export interface LeafEntry extends SessionTreeEntryBase { +interface LeafEntry extends SessionTreeEntryBase { type: "leaf"; targetId: string | null; - /** Raw parent for the next append when it differs from the visible leaf. */ appendParentId?: string | null; } -/** All persisted session tree entry variants. */ export type SessionTreeEntry = | MessageEntry | ThinkingLevelChangeEntry @@ -452,385 +118,14 @@ export interface SessionContext { 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; - getAppendParentId?(): 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 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; - -/** Hook result for mutating the initial prompt run before the agent starts. */ -export interface BeforeAgentStartResult { - /** Replacement messages for the prompt run. */ - messages?: AgentMessage[]; - /** Replacement system prompt for the prompt run. */ - systemPrompt?: string; -} - -/** Hook result for replacing the full context message list before provider conversion. */ -export interface ContextResult { - messages: AgentMessage[]; -} - -/** Hook result for patching provider request options before payload construction. */ -export interface BeforeProviderRequestResult { - streamOptions?: AgentHarnessStreamOptionsPatch; -} - -/** Hook result for replacing the provider payload after construction. */ -export interface BeforeProviderPayloadResult { - payload: unknown; -} - -/** Hook result for blocking a tool call before execution. */ -export interface ToolCallResult { - block?: boolean; - reason?: string; -} - -/** Hook patch for a completed tool result before it is persisted/emitted. */ -export interface ToolResultPatch { - content?: Array; - details?: unknown; - isError?: boolean; - terminate?: boolean; -} - -/** Hook result for cancelling or replacing a planned compaction. */ -export interface SessionBeforeCompactResult { - cancel?: boolean; - compaction?: CompactResult; -} - -/** Hook result for cancelling, labeling, or supplying branch-summary behavior before tree navigation. */ -export interface SessionBeforeTreeResult { - cancel?: boolean; - summary?: { summary: string; details?: unknown }; - customInstructions?: string; - replaceInstructions?: boolean; - label?: string; -} - -/** Typed return values expected from AgentHarness hook handlers by event type. */ -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; -}; - -/** Queued messages removed by an abort operation. */ -export interface AbortResult { - clearedSteer: AgentMessage[]; - clearedFollowUp: AgentMessage[]; -} - -/** Compaction data supplied by hooks or returned from compaction preparation. */ -export interface CompactResult { - summary: string; - firstKeptEntryId: string; - tokensBefore: number; - details?: unknown; -} - -/** Result of moving the active session-tree leaf. */ -export interface NavigateTreeResult { - cancelled: boolean; - editorText?: string; - summaryEntry?: BranchSummaryEntry; -} - -/** Settings that control automatic context compaction. */ -export interface CompactionSettings { - enabled: boolean; - reserveTokens: number; - keepRecentTokens: number; -} - -/** Prepared compaction inputs exposed to hooks before a summary is generated. */ -export interface CompactionPreparation { - firstKeptEntryId: string; - messagesToSummarize: AgentMessage[]; - turnPrefixMessages: AgentMessage[]; - isSplitTurn: boolean; - tokensBefore: number; - previousSummary?: string; - fileOps: FileOperations; - settings: CompactionSettings; -} - -/** File operations accumulated from summarized transcript ranges. */ export interface FileOperations { read: Set; written: Set; edited: Set; } -/** Prepared branch navigation inputs exposed to hooks before a summary is generated. */ -export interface TreePreparation { - targetId: string; - oldLeafId: string | null; - commonAncestorId: string | null; - entriesToSummarize: SessionTreeEntry[]; - userWantsSummary: boolean; - customInstructions?: string; - replaceInstructions?: boolean; - label?: string; -} - -/** Options for generating a branch summary. */ -export interface GenerateBranchSummaryOptions { - model: Model; - apiKey: string; - headers?: Record; - signal: AbortSignal; - runtime?: AgentCoreCompletionRuntimeDeps; - streamFn?: StreamFn; - customInstructions?: string; - replaceInstructions?: boolean; - reserveTokens?: number; -} - -/** Generated branch summary text and file-operation metadata. */ export interface BranchSummaryResult { summary: string; readFiles: string[]; modifiedFiles: string[]; } - -/** Construction options for AgentHarness. */ -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>; - runtime?: AgentCoreRuntimeDeps; - /** Curated stream/provider request options. Snapshotted at turn start. */ - streamOptions?: AgentHarnessStreamOptions; - model: Model; - thinkingLevel?: ThinkingLevel; - activeToolNames?: string[]; - steeringMode?: QueueMode; - followUpMode?: QueueMode; -} - -export type { CoreAgentHarness as AgentHarness } from "./agent-harness.js"; diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 165fbf5aae73..bf4f59a7746f 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -1,29 +1,27 @@ -// Public agent-core package surface: agent loop, harness, session storage, -// compaction, execution envs, and utility helpers. +// Public agent-core package surface: agent loop, compaction, session context, +// and the focused helpers consumed by OpenClaw. export * from "./agent.js"; export * from "./agent-loop.js"; export * from "./errors.js"; -export * from "./node.js"; export * from "./runtime-deps.js"; export * from "./types.js"; export * from "./validation.js"; -export * from "./harness/agent-harness.js"; export * from "./harness/env/kill-tree.js"; export * from "./harness/messages.js"; export * from "./harness/prompt-template-arguments.js"; -export * from "./harness/skills.js"; -export * from "./harness/types.js"; -export * from "./harness/session/jsonl-storage.js"; -export * from "./harness/session/memory-storage.js"; -export * from "./harness/session/session.js"; +export { buildSessionContext } from "./harness/session/session.js"; export { uuidv7 } from "./harness/session/uuid.js"; +export type { + BranchSummaryResult, + FileOperations, + Result, + SessionTreeEntry, +} from "./harness/types.js"; export { type BranchPreparation, type BranchPathEntry, type BranchSummaryDetails, type CollectBranchPathEntriesResult, - type CollectEntriesResult, - collectEntriesForBranchSummary, collectEntriesForBranchSummaryFromBranches, generateBranchSummary, prepareBranchEntries, diff --git a/packages/agent-core/src/node.ts b/packages/agent-core/src/node.ts deleted file mode 100644 index 4ff3f5c62321..000000000000 --- a/packages/agent-core/src/node.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Node-specific agent-core entrypoint with the default Node execution env. -export { NodeExecutionEnv } from "./harness/env/nodejs.js"; -export * from "./index.js"; diff --git a/packages/agent-core/src/reasoning.test.ts b/packages/agent-core/src/reasoning.test.ts index 3fbdd6d448a2..90fab943b4e7 100644 --- a/packages/agent-core/src/reasoning.test.ts +++ b/packages/agent-core/src/reasoning.test.ts @@ -1,5 +1,5 @@ +import type { Model } from "@openclaw/llm-core"; import { describe, expect, it } from "vitest"; -import type { Model } from "../../llm-core/src/index.js"; import { resolveAgentReasoningOption } from "./reasoning.js"; function makeModel( diff --git a/packages/agent-core/src/reasoning.ts b/packages/agent-core/src/reasoning.ts index ca93e5a90bf5..8a8b891850fd 100644 --- a/packages/agent-core/src/reasoning.ts +++ b/packages/agent-core/src/reasoning.ts @@ -3,7 +3,7 @@ import { resolveClaudeSonnet5ModelIdentity, type Model, type SimpleStreamOptions, -} from "../../llm-core/src/index.js"; +} from "@openclaw/llm-core"; import type { ThinkingLevel } from "./types.js"; type EnabledThinkingLevel = Exclude, "off">; diff --git a/packages/agent-core/src/runtime-deps.ts b/packages/agent-core/src/runtime-deps.ts index d30b93afbc8d..492eda15bc8a 100644 --- a/packages/agent-core/src/runtime-deps.ts +++ b/packages/agent-core/src/runtime-deps.ts @@ -1,5 +1,5 @@ // Agent Core module implements runtime deps behavior. -import type { CompleteSimpleFn, StreamFn } from "../../llm-core/src/index.js"; +import type { CompleteSimpleFn, StreamFn } from "@openclaw/llm-core"; /** Runtime functions injected by host packages so agent-core stays provider-agnostic. */ export interface AgentCoreRuntimeDeps { diff --git a/packages/agent-core/src/turn-interruption.ts b/packages/agent-core/src/turn-interruption.ts index a1272a218ac3..6ef573a6ca6c 100644 --- a/packages/agent-core/src/turn-interruption.ts +++ b/packages/agent-core/src/turn-interruption.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage, Model } from "../../llm-core/src/index.js"; +import type { AssistantMessage, Model } from "@openclaw/llm-core"; import type { AgentEvent, AgentMessage } from "./types.js"; /** Canonical empty aborted/error assistant recorded when a run ends without output. */ diff --git a/packages/agent-core/src/types.ts b/packages/agent-core/src/types.ts index 4d8c9b46e010..0e84b8d77ed8 100644 --- a/packages/agent-core/src/types.ts +++ b/packages/agent-core/src/types.ts @@ -1,5 +1,3 @@ -// Agent Core type module defines shared TypeScript contracts. -import type { Static, TSchema } from "typebox"; import type { AssistantMessage, AssistantMessageEvent, @@ -11,7 +9,9 @@ import type { TextContent, Tool, ToolResultMessage, -} from "../../llm-core/src/index.js"; +} from "@openclaw/llm-core"; +// Agent Core type module defines shared TypeScript contracts. +import type { Static, TSchema } from "typebox"; /** * Stream function used by the agent loop. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89c30b76c162..3e7f76694f26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2054,6 +2054,9 @@ importers: '@openclaw/ai': specifier: workspace:* version: link:../ai + '@openclaw/llm-core': + specifier: workspace:* + version: link:../llm-core '@openclaw/normalization-core': specifier: workspace:* version: link:../normalization-core diff --git a/tsdown.config.ts b/tsdown.config.ts index fa11d3c4bd74..0281ff576a3c 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -351,24 +351,16 @@ function buildAgentCoreDistEntries(): Record { agent: "packages/agent-core/src/agent.ts", "agent-loop": "packages/agent-core/src/agent-loop.ts", llm: "packages/agent-core/src/llm.ts", - node: "packages/agent-core/src/node.ts", "runtime-deps": "packages/agent-core/src/runtime-deps.ts", types: "packages/agent-core/src/types.ts", validation: "packages/agent-core/src/validation.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/env/kill-tree": "packages/agent-core/src/harness/env/kill-tree.ts", - "harness/session": "packages/agent-core/src/harness/session/session.ts", - "harness/session/jsonl-storage": "packages/agent-core/src/harness/session/jsonl-storage.ts", - "harness/session/memory-storage": "packages/agent-core/src/harness/session/memory-storage.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-template-arguments": "packages/agent-core/src/harness/prompt-template-arguments.ts", - "harness/skills": "packages/agent-core/src/harness/skills.ts", "harness/utils/truncate": "packages/agent-core/src/harness/utils/truncate.ts", }; }