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