330db43b5f
Each turn (assistant response / tool result) is appended to a JSONL file at ~/.uncaged/workflow/sessions/<sessionId>.jsonl during the loop. On completion, the JSONL is read back, each turn is stored as a CAS node, and the detail payload references them as a flat turns[] array in chronological order. The session file is then deleted. Benefits: - Real-time observability: tail -f the JSONL to watch loop progress - Crash recovery: partial JSONL survives process death - Zero write contention: one file per session - Detail stays a flat array for easy consumption by CLI/dashboard Changes: - New session.ts: initSessionDir, appendSessionTurn, readSessionTurns, removeSession - loop.ts: append JSONL each turn instead of accumulating in-memory - detail.ts: reads session JSONL → persists turns to CAS → stores detail - agent.ts: passes storageRoot/sessionId to loop, cleans up session on completion - types.ts: remove index from TurnPayload (order is implicit in JSONL/array) - schemas.ts: sync with type changes Ref: #433
139 lines
3.7 KiB
TypeScript
139 lines
3.7 KiB
TypeScript
import type { ResolvedLlmProvider } from "@uncaged/workflow-agent-kit";
|
|
import { createLogger } from "@uncaged/workflow-util";
|
|
|
|
import { type ChatMessage, chatCompletionWithTools, type LlmToolCall } from "./llm/index.js";
|
|
import { appendSessionTurn } from "./session.js";
|
|
import {
|
|
builtinToolsToOpenAi,
|
|
executeBuiltinTool,
|
|
getBuiltinTools,
|
|
type ToolContext,
|
|
} from "./tools/index.js";
|
|
import type { BuiltinToolCall, BuiltinTurnPayload } from "./types.js";
|
|
|
|
const log = createLogger({ sink: { kind: "stderr" } });
|
|
|
|
export const BUILTIN_MAX_TURNS = 30;
|
|
export const BUILTIN_CONTINUE_MAX_TURNS = 5;
|
|
|
|
export type RunBuiltinLoopOptions = {
|
|
provider: ResolvedLlmProvider;
|
|
messages: ChatMessage[];
|
|
toolCtx: ToolContext;
|
|
maxTurns: number;
|
|
storageRoot: string;
|
|
sessionId: string;
|
|
};
|
|
|
|
export type RunBuiltinLoopResult = {
|
|
finalText: string;
|
|
messages: ChatMessage[];
|
|
turnCount: number;
|
|
};
|
|
|
|
function mapToolCallsForPayload(calls: LlmToolCall[]): BuiltinToolCall[] {
|
|
return calls.map((call) => ({
|
|
name: call.name,
|
|
args: call.arguments,
|
|
}));
|
|
}
|
|
|
|
async function appendTurn(
|
|
storageRoot: string,
|
|
sessionId: string,
|
|
payload: BuiltinTurnPayload,
|
|
): Promise<void> {
|
|
await appendSessionTurn(storageRoot, sessionId, payload);
|
|
}
|
|
|
|
async function executeTurnTools(
|
|
calls: Array<{ id: string; name: string; arguments: string }>,
|
|
toolCtx: ToolContext,
|
|
messages: ChatMessage[],
|
|
storageRoot: string,
|
|
sessionId: string,
|
|
): Promise<number> {
|
|
let turnCount = 0;
|
|
for (const call of calls) {
|
|
const result = await executeBuiltinTool(call.name, call.arguments, toolCtx);
|
|
messages.push({ role: "tool", tool_call_id: call.id, content: result });
|
|
await appendTurn(storageRoot, sessionId, {
|
|
role: "tool",
|
|
content: result,
|
|
toolCalls: null,
|
|
reasoning: null,
|
|
});
|
|
turnCount += 1;
|
|
}
|
|
return turnCount;
|
|
}
|
|
|
|
/** Agent run loop: LLM ↔ tools until no tool_calls or maxTurns. */
|
|
export async function runBuiltinLoop(
|
|
options: RunBuiltinLoopOptions,
|
|
): Promise<RunBuiltinLoopResult> {
|
|
const messages = [...options.messages];
|
|
const openAiTools = builtinToolsToOpenAi(getBuiltinTools());
|
|
let finalText = "";
|
|
let turnCount = 0;
|
|
|
|
for (let turn = 0; turn < options.maxTurns; turn++) {
|
|
log("8K2M4N7P", `builtin loop turn ${turn + 1}/${options.maxTurns}`);
|
|
const response = await chatCompletionWithTools(options.provider, messages, openAiTools);
|
|
|
|
const assistantMessage: ChatMessage = {
|
|
role: "assistant",
|
|
content: response.content,
|
|
tool_calls: response.toolCalls,
|
|
};
|
|
messages.push(assistantMessage);
|
|
|
|
if (response.toolCalls === null || response.toolCalls.length === 0) {
|
|
finalText = response.content ?? "";
|
|
await appendTurn(options.storageRoot, options.sessionId, {
|
|
role: "assistant",
|
|
content: response.content ?? "",
|
|
toolCalls: null,
|
|
reasoning: null,
|
|
});
|
|
turnCount += 1;
|
|
break;
|
|
}
|
|
|
|
// Assistant turn with tool calls
|
|
await appendTurn(options.storageRoot, options.sessionId, {
|
|
role: "assistant",
|
|
content: response.content ?? "",
|
|
toolCalls: mapToolCallsForPayload(response.toolCalls),
|
|
reasoning: null,
|
|
});
|
|
turnCount += 1;
|
|
|
|
// Execute tools
|
|
turnCount += await executeTurnTools(
|
|
response.toolCalls,
|
|
options.toolCtx,
|
|
messages,
|
|
options.storageRoot,
|
|
options.sessionId,
|
|
);
|
|
}
|
|
|
|
if (finalText === "" && messages.length > 0) {
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
const msg = messages[i];
|
|
if (
|
|
msg !== undefined &&
|
|
msg.role === "assistant" &&
|
|
msg.content !== null &&
|
|
msg.content.trim() !== ""
|
|
) {
|
|
finalText = msg.content;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { finalText, messages, turnCount };
|
|
}
|