Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 842e479784 | |||
| f63c670cd9 | |||
| 64c5122453 | |||
| 5b68359dfc | |||
| c2ddfb8558 | |||
| 603018caf2 | |||
| aff0ee6fea | |||
| d37fa1393a | |||
| 759c784267 | |||
| 52ffc7dcc1 | |||
| ac55a3e3d9 | |||
| edb979baa9 | |||
| 3d1850ddbe | |||
| 3c1f4a6dfa | |||
| f07a6daa30 | |||
| 0eeb4a8ed8 | |||
| a3fac708b6 | |||
| 52879c0028 | |||
| 8720eb19af | |||
| 9e4527bb89 | |||
| 5209cfa7ac | |||
| 155b879d29 | |||
| c1f04929f4 | |||
| 50cd93aa05 | |||
| 1abc3b4cf4 | |||
| 330db43b5f |
@@ -41,7 +41,8 @@ roles:
|
||||
Before starting any work, ensure a clean worktree:
|
||||
1. `git checkout main && git pull` to get the latest code
|
||||
2. `git checkout -b fix/<issue-number>-<short-description>` to create a fresh branch
|
||||
- If bounced back from reviewer or tester, reuse the existing branch instead
|
||||
- If bounced back from reviewer or tester, reuse the existing branch and rebase onto latest main:
|
||||
`git checkout main && git pull && git checkout <branch> && git rebase main`
|
||||
|
||||
Then implement TDD:
|
||||
3. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
|
||||
|
||||
@@ -17,6 +17,15 @@
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"cssModules": true,
|
||||
"tailwindDirectives": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
|
||||
"typecheck": "bunx tsc --build",
|
||||
"format": "biome format --write .",
|
||||
"test": "bun run --filter '*' test",
|
||||
"test": "bun run --filter './packages/*' test",
|
||||
"changeset": "bunx changeset",
|
||||
"version": "bunx changeset version",
|
||||
"release": "bun run build && bun test && node scripts/publish-all.mjs"
|
||||
|
||||
@@ -266,12 +266,7 @@ describe("cmdThreadRead ### Content section", () => {
|
||||
|
||||
expect(markdown).toContain("### Content");
|
||||
expect(markdown).toContain("The assistant response text");
|
||||
|
||||
const contentIdx = markdown.indexOf("### Content");
|
||||
const outputIdx = markdown.indexOf("### Output");
|
||||
expect(contentIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(outputIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(contentIdx).toBeLessThan(outputIdx);
|
||||
expect(markdown).not.toContain("### Output");
|
||||
});
|
||||
|
||||
test("omits ### Content when detail has no matching assistant turns", async () => {
|
||||
@@ -314,7 +309,7 @@ describe("cmdThreadRead ### Content section", () => {
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||
|
||||
expect(markdown).not.toContain("### Content");
|
||||
expect(markdown).toContain("### Output");
|
||||
expect(markdown).not.toContain("### Output");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -392,3 +387,87 @@ describe("cmdThreadStepDetails", () => {
|
||||
await expect(cmdThreadStepDetails(tmpDir, "nonexistenth0" as CasRef)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── cmdThreadRead: ### Prompt deduplication ───────────────────────────────────
|
||||
|
||||
describe("cmdThreadRead ### Prompt deduplication", () => {
|
||||
async function makeThreadWithRoles(uwf: UwfStore, roles: string[]): Promise<string> {
|
||||
const roleMap: Record<string, unknown> = {};
|
||||
for (const r of [...new Set(roles)]) {
|
||||
roleMap[r] = {
|
||||
description: r,
|
||||
goal: `Goal for ${r}`,
|
||||
capabilities: [],
|
||||
procedure: "Do stuff.",
|
||||
output: "Output.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
};
|
||||
}
|
||||
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "dedup-wf",
|
||||
description: "desc",
|
||||
roles: roleMap,
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Start",
|
||||
});
|
||||
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "out",
|
||||
description: "",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
let prev: string | null = null;
|
||||
let stepHash = "";
|
||||
for (const role of roles) {
|
||||
stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: prev as CasRef | null,
|
||||
role,
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
prev = stepHash;
|
||||
}
|
||||
return stepHash;
|
||||
}
|
||||
|
||||
test("same consecutive role shows ### Prompt once", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const headHash = await makeThreadWithRoles(uwf, ["writer", "writer"]);
|
||||
const threadId = "01JTEST0000000000000003" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||
const count = (markdown.match(/### Prompt/g) ?? []).length;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
test("different consecutive roles each show ### Prompt", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const headHash = await makeThreadWithRoles(uwf, ["planner", "coder"]);
|
||||
const threadId = "01JTEST0000000000000004" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||
const count = (markdown.match(/### Prompt/g) ?? []).length;
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
test("non-consecutive same role shows ### Prompt twice", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const headHash = await makeThreadWithRoles(uwf, ["roleA", "roleB", "roleA"]);
|
||||
const threadId = "01JTEST0000000000000005" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||
const count = (markdown.match(/### Prompt/g) ?? []).length;
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -539,7 +539,7 @@ function collectOrderedSteps(
|
||||
}
|
||||
|
||||
function formatYaml(value: unknown): string {
|
||||
return stringify(value).trimEnd();
|
||||
return stringify(value, { aliasDuplicateObjects: false }).trimEnd();
|
||||
}
|
||||
|
||||
function formatCompactStep(index: number, item: OrderedStepItem, outputYaml: string): string {
|
||||
@@ -655,11 +655,11 @@ function formatThreadReadMarkdown(options: {
|
||||
|
||||
// Step blocks
|
||||
const startIndex = candidates.length - selected.length;
|
||||
const shownPromptRoles = new Set<string>();
|
||||
for (let i = 0; i < selected.length; i++) {
|
||||
const item = selected[i];
|
||||
if (item === undefined) continue;
|
||||
const stepNum = startIndex + i + 1;
|
||||
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
|
||||
const ts = new Date(item.timestamp)
|
||||
.toISOString()
|
||||
.replace("T", " ")
|
||||
@@ -669,9 +669,10 @@ function formatThreadReadMarkdown(options: {
|
||||
`**Agent:** ${item.payload.agent} | **Time:** ${ts}`,
|
||||
];
|
||||
const roleDef = workflow.roles[item.payload.role];
|
||||
if (roleDef) {
|
||||
if (roleDef && !shownPromptRoles.has(item.payload.role)) {
|
||||
const prompt = roleDef.goal;
|
||||
stepLines.push("", "### Prompt", "", prompt);
|
||||
shownPromptRoles.add(item.payload.role);
|
||||
}
|
||||
if (item.payload.detail) {
|
||||
const content = extractLastAssistantContent(uwf, item.payload.detail);
|
||||
@@ -679,7 +680,6 @@ function formatThreadReadMarkdown(options: {
|
||||
stepLines.push("", "### Content", "", content);
|
||||
}
|
||||
}
|
||||
stepLines.push("", "### Output", "", "```yaml", outputYaml, "```");
|
||||
parts.push(stepLines.join("\n"));
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,6 @@ export function formatOutput(data: unknown, format: OutputFormat): string {
|
||||
case "json":
|
||||
return JSON.stringify(data);
|
||||
case "yaml":
|
||||
return stringify(data).trimEnd();
|
||||
return stringify(data, { aliasDuplicateObjects: false }).trimEnd();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
|
||||
const mockChatCompletionWithTools = mock(async () => ({
|
||||
content: "---\nstatus: done\n---",
|
||||
toolCalls: [],
|
||||
}));
|
||||
const mockAppendSessionTurn = mock(async () => {});
|
||||
const mockExecuteBuiltinTool = mock(async () => "tool-result");
|
||||
|
||||
mock.module("../src/llm/index.js", () => ({
|
||||
chatCompletionWithTools: mockChatCompletionWithTools,
|
||||
}));
|
||||
mock.module("../src/session.js", () => ({
|
||||
appendSessionTurn: mockAppendSessionTurn,
|
||||
}));
|
||||
mock.module("../src/tools/index.js", () => ({
|
||||
builtinToolsToOpenAi: () => [],
|
||||
executeBuiltinTool: mockExecuteBuiltinTool,
|
||||
getBuiltinTools: () => [],
|
||||
}));
|
||||
|
||||
import { executeTurnTools, runBuiltinLoop, shouldNudge } from "../src/loop.js";
|
||||
|
||||
const fakeProvider = {} as any;
|
||||
const fakeToolCtx = {} as any;
|
||||
|
||||
function makeOptions(overrides: Partial<Parameters<typeof runBuiltinLoop>[0]> = {}) {
|
||||
return {
|
||||
provider: fakeProvider,
|
||||
messages: [{ role: "system" as const, content: "sys" }],
|
||||
toolCtx: fakeToolCtx,
|
||||
maxTurns: 5,
|
||||
storageRoot: "/tmp",
|
||||
sessionId: "sess",
|
||||
noTools: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockChatCompletionWithTools.mockReset();
|
||||
mockAppendSessionTurn.mockReset();
|
||||
mockExecuteBuiltinTool.mockReset();
|
||||
});
|
||||
|
||||
describe("shouldNudge", () => {
|
||||
test("2.1 returns true when all conditions met", () => {
|
||||
expect(shouldNudge({ noTools: false, text: "some text", turn: 0, maxTurns: 5 })).toBe(true);
|
||||
});
|
||||
test("2.2 returns false when noTools=true", () => {
|
||||
expect(shouldNudge({ noTools: true, text: "some text", turn: 0, maxTurns: 5 })).toBe(false);
|
||||
});
|
||||
test("2.3 returns false when text starts with ---", () => {
|
||||
expect(shouldNudge({ noTools: false, text: "---\nstatus: done", turn: 0, maxTurns: 5 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
test("2.4 returns false on last turn", () => {
|
||||
expect(shouldNudge({ noTools: false, text: "some text", turn: 4, maxTurns: 5 })).toBe(false);
|
||||
});
|
||||
test("2.5 returns true on second-to-last turn", () => {
|
||||
expect(shouldNudge({ noTools: false, text: "some text", turn: 3, maxTurns: 5 })).toBe(true);
|
||||
});
|
||||
test("2.6 leading whitespace before --- suppresses nudge", () => {
|
||||
expect(shouldNudge({ noTools: false, text: " ---\nstatus: done", turn: 0, maxTurns: 5 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeTurnTools", () => {
|
||||
test("4.1 executes each tool call and pushes tool result messages", async () => {
|
||||
mockExecuteBuiltinTool.mockResolvedValue("result");
|
||||
const messages: any[] = [];
|
||||
const calls = [
|
||||
{ id: "c1", name: "tool_a", arguments: "{}" },
|
||||
{ id: "c2", name: "tool_b", arguments: "{}" },
|
||||
];
|
||||
const count = await executeTurnTools(calls, fakeToolCtx, messages, "/tmp", "sess");
|
||||
expect(messages.length).toBe(2);
|
||||
expect(messages[0].role).toBe("tool");
|
||||
expect(messages[1].role).toBe("tool");
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
test("4.2 tool result content matches executeBuiltinTool return value", async () => {
|
||||
mockExecuteBuiltinTool.mockResolvedValue("result-A");
|
||||
const messages: any[] = [];
|
||||
await executeTurnTools(
|
||||
[{ id: "c1", name: "read_file", arguments: "{}" }],
|
||||
fakeToolCtx,
|
||||
messages,
|
||||
"/tmp",
|
||||
"sess",
|
||||
);
|
||||
expect(messages[0].content).toBe("result-A");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runBuiltinLoop integration", () => {
|
||||
test("3.1 single text-only response returns finalText immediately", async () => {
|
||||
mockChatCompletionWithTools.mockResolvedValue({
|
||||
content: "---\nstatus: done\n---",
|
||||
toolCalls: [],
|
||||
});
|
||||
const result = await runBuiltinLoop(makeOptions());
|
||||
expect(result.finalText).toBe("---\nstatus: done\n---");
|
||||
expect(result.turnCount).toBe(1);
|
||||
});
|
||||
test("3.2 noTools=true suppresses tool calls", async () => {
|
||||
mockChatCompletionWithTools.mockResolvedValue({
|
||||
content: "ok",
|
||||
toolCalls: [{ id: "c1", name: "read_file", arguments: "{}" }],
|
||||
});
|
||||
const result = await runBuiltinLoop(makeOptions({ noTools: true }));
|
||||
expect(result.finalText).toBe("ok");
|
||||
expect(result.turnCount).toBe(1);
|
||||
});
|
||||
test("3.3 tool call followed by text response", async () => {
|
||||
mockChatCompletionWithTools
|
||||
.mockResolvedValueOnce({
|
||||
content: null,
|
||||
toolCalls: [{ id: "c1", name: "read_file", arguments: "{}" }],
|
||||
})
|
||||
.mockResolvedValueOnce({ content: "---\nstatus: done\n---", toolCalls: [] });
|
||||
mockExecuteBuiltinTool.mockResolvedValue("file contents");
|
||||
const result = await runBuiltinLoop(makeOptions());
|
||||
expect(result.finalText).toBe("---\nstatus: done\n---");
|
||||
expect(result.turnCount).toBe(3);
|
||||
});
|
||||
test("3.4 nudge cycle inserts nudge message", async () => {
|
||||
mockChatCompletionWithTools
|
||||
.mockResolvedValueOnce({ content: "I am thinking", toolCalls: [] })
|
||||
.mockResolvedValueOnce({ content: "---\nstatus: done\n---", toolCalls: [] });
|
||||
const result = await runBuiltinLoop(makeOptions());
|
||||
expect(result.finalText).toBe("---\nstatus: done\n---");
|
||||
const nudgeMsg = result.messages.find(
|
||||
(m) =>
|
||||
m.role === "user" && typeof m.content === "string" && m.content.includes("frontmatter"),
|
||||
);
|
||||
expect(nudgeMsg).toBeDefined();
|
||||
});
|
||||
test("3.5 maxTurns exhaustion falls back to last assistant content", async () => {
|
||||
mockChatCompletionWithTools.mockResolvedValue({ content: "still thinking", toolCalls: [] });
|
||||
const result = await runBuiltinLoop(makeOptions({ maxTurns: 3 }));
|
||||
expect(result.finalText).toBe("still thinking");
|
||||
});
|
||||
test("3.6 original messages array is not mutated", async () => {
|
||||
mockChatCompletionWithTools.mockResolvedValue({
|
||||
content: "---\nstatus: done\n---",
|
||||
toolCalls: [],
|
||||
});
|
||||
const original = [{ role: "system" as const, content: "sys" }];
|
||||
await runBuiltinLoop(makeOptions({ messages: original }));
|
||||
expect(original.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -7,17 +7,44 @@ import {
|
||||
resolveModel,
|
||||
resolveStorageRoot,
|
||||
} from "@uncaged/workflow-agent-kit";
|
||||
import { generateUlid } from "@uncaged/workflow-util";
|
||||
import { createLogger, generateUlid } from "@uncaged/workflow-util";
|
||||
|
||||
import { storeBuiltinDetail } from "./detail.js";
|
||||
import type { ChatMessage } from "./llm/index.js";
|
||||
import { BUILTIN_CONTINUE_MAX_TURNS, BUILTIN_MAX_TURNS, runBuiltinLoop } from "./loop.js";
|
||||
import { buildBuiltinMessages } from "./prompt.js";
|
||||
import type { BuiltinSessionState } from "./types.js";
|
||||
import { initSessionDir } from "./session.js";
|
||||
|
||||
const sessions = new Map<string, BuiltinSessionState>();
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
function getSession(sessionId: string): BuiltinSessionState {
|
||||
const FRONTMATTER_FENCE = "---";
|
||||
|
||||
/**
|
||||
* Strip any text before the first `---` fence.
|
||||
* LLMs sometimes emit preamble text before the frontmatter block.
|
||||
*/
|
||||
function stripPreamble(text: string): string {
|
||||
if (text.startsWith(FRONTMATTER_FENCE)) {
|
||||
return text;
|
||||
}
|
||||
const idx = text.indexOf(`\n${FRONTMATTER_FENCE}\n`);
|
||||
if (idx !== -1) {
|
||||
log("6GWRP3QX", `stripped ${idx + 1} chars of preamble before frontmatter`);
|
||||
return text.slice(idx + 1);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
type SessionRecord = {
|
||||
sessionId: string;
|
||||
model: string;
|
||||
startedAtMs: number;
|
||||
messages: ChatMessage[];
|
||||
};
|
||||
|
||||
const sessions = new Map<string, SessionRecord>();
|
||||
|
||||
function getSession(sessionId: string): SessionRecord {
|
||||
const session = sessions.get(sessionId);
|
||||
if (session === undefined) {
|
||||
throw new Error(`builtin session not found: ${sessionId}`);
|
||||
@@ -36,31 +63,38 @@ async function runBuiltinWithMessages(
|
||||
storageRoot: string,
|
||||
provider: ReturnType<typeof resolveModel>,
|
||||
messages: ChatMessage[],
|
||||
session: BuiltinSessionState,
|
||||
session: SessionRecord,
|
||||
store: Store,
|
||||
maxTurns: number,
|
||||
noTools: boolean,
|
||||
): Promise<AgentRunResult> {
|
||||
const loopResult = await runBuiltinLoop({
|
||||
provider,
|
||||
messages,
|
||||
toolCtx: buildToolContext(storageRoot),
|
||||
maxTurns,
|
||||
existingTurns: session.turns,
|
||||
storageRoot,
|
||||
sessionId: session.sessionId,
|
||||
noTools,
|
||||
});
|
||||
|
||||
session.messages = loopResult.messages;
|
||||
session.turns = loopResult.turns;
|
||||
|
||||
const { detailHash, output } = await storeBuiltinDetail(
|
||||
if (loopResult.turnCount === 0) {
|
||||
log("5RWTK9NB", "no turns produced, returning empty output");
|
||||
return { output: "", detailHash: "", sessionId: session.sessionId };
|
||||
}
|
||||
|
||||
// Read jsonl → persist turns to CAS → store detail
|
||||
const { detailHash } = await storeBuiltinDetail(
|
||||
store,
|
||||
storageRoot,
|
||||
session.sessionId,
|
||||
session.model,
|
||||
session.startedAtMs,
|
||||
session.turns,
|
||||
);
|
||||
|
||||
const finalOutput = output !== "" ? output : loopResult.finalText;
|
||||
return { output: finalOutput, detailHash, sessionId: session.sessionId };
|
||||
return { output: stripPreamble(loopResult.finalText), detailHash, sessionId: session.sessionId };
|
||||
}
|
||||
|
||||
async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
@@ -69,14 +103,14 @@ async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
const provider = resolveModel(config, config.defaultModel);
|
||||
|
||||
const sessionId = generateUlid(Date.now());
|
||||
await initSessionDir(storageRoot);
|
||||
const messages = buildBuiltinMessages(ctx);
|
||||
|
||||
const session: BuiltinSessionState = {
|
||||
const session: SessionRecord = {
|
||||
sessionId,
|
||||
model: provider.model,
|
||||
startedAtMs: Date.now(),
|
||||
messages,
|
||||
turns: [],
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
@@ -87,6 +121,7 @@ async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
session,
|
||||
ctx.store,
|
||||
BUILTIN_MAX_TURNS,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,6 +144,7 @@ async function continueBuiltin(
|
||||
session,
|
||||
store,
|
||||
BUILTIN_CONTINUE_MAX_TURNS,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,72 +1,15 @@
|
||||
import { bootstrap, putSchema, type Store } from "@uncaged/json-cas";
|
||||
|
||||
import { BUILTIN_DETAIL_SCHEMA, BUILTIN_TURN_SCHEMA } from "./schemas.js";
|
||||
import type {
|
||||
BuiltinDetailPayload,
|
||||
BuiltinLoopTurn,
|
||||
BuiltinToolCall,
|
||||
BuiltinTurnPayload,
|
||||
BuiltinTurnRole,
|
||||
} from "./types.js";
|
||||
|
||||
function mapToolCalls(calls: NonNullable<BuiltinLoopTurn["toolCalls"]>): BuiltinToolCall[] {
|
||||
return calls.map((call) => ({
|
||||
name: call.name,
|
||||
args: call.args,
|
||||
}));
|
||||
}
|
||||
|
||||
function loopTurnToAssistantPayload(turn: BuiltinLoopTurn, index: number): BuiltinTurnPayload {
|
||||
return {
|
||||
index,
|
||||
role: "assistant",
|
||||
content: turn.assistantContent ?? "",
|
||||
toolCalls:
|
||||
turn.toolCalls !== null && turn.toolCalls.length > 0 ? mapToolCalls(turn.toolCalls) : null,
|
||||
reasoning: null,
|
||||
};
|
||||
}
|
||||
|
||||
function loopTurnToToolPayloads(turn: BuiltinLoopTurn, startIndex: number): BuiltinTurnPayload[] {
|
||||
if (turn.toolResults === null || turn.toolResults.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const payloads: BuiltinTurnPayload[] = [];
|
||||
let index = startIndex;
|
||||
for (const result of turn.toolResults) {
|
||||
payloads.push({
|
||||
index,
|
||||
role: "tool" as BuiltinTurnRole,
|
||||
content: result.content,
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
index += 1;
|
||||
}
|
||||
return payloads;
|
||||
}
|
||||
|
||||
/** Last assistant message with non-empty text. */
|
||||
export function extractFinalAssistantText(turns: BuiltinLoopTurn[]): string {
|
||||
for (let i = turns.length - 1; i >= 0; i--) {
|
||||
const turn = turns[i];
|
||||
if (turn === undefined) {
|
||||
continue;
|
||||
}
|
||||
const text = turn.assistantContent;
|
||||
if (text !== null && text.trim() !== "") {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
import { readSessionTurns } from "./session.js";
|
||||
import type { BuiltinDetailPayload } from "./types.js";
|
||||
|
||||
type BuiltinSchemaHashes = {
|
||||
turn: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
async function registerBuiltinSchemas(store: Store): Promise<BuiltinSchemaHashes> {
|
||||
export async function registerBuiltinSchemas(store: Store): Promise<BuiltinSchemaHashes> {
|
||||
await bootstrap(store);
|
||||
const [turn, detail] = await Promise.all([
|
||||
putSchema(store, BUILTIN_TURN_SCHEMA),
|
||||
@@ -75,30 +18,22 @@ async function registerBuiltinSchemas(store: Store): Promise<BuiltinSchemaHashes
|
||||
return { turn, detail };
|
||||
}
|
||||
|
||||
/** Read session jsonl, persist each turn to CAS, return detail hash. */
|
||||
export async function storeBuiltinDetail(
|
||||
store: Store,
|
||||
storageRoot: string,
|
||||
sessionId: string,
|
||||
model: string,
|
||||
startedAtMs: number,
|
||||
turns: BuiltinLoopTurn[],
|
||||
nowMs: number = Date.now(),
|
||||
): Promise<{ detailHash: string; output: string }> {
|
||||
): Promise<{ detailHash: string; turnCount: number }> {
|
||||
const schemas = await registerBuiltinSchemas(store);
|
||||
const turns = await readSessionTurns(storageRoot, sessionId);
|
||||
|
||||
const turnHashes: string[] = [];
|
||||
let turnIndex = 0;
|
||||
|
||||
for (const loopTurn of turns) {
|
||||
const assistant = loopTurnToAssistantPayload(loopTurn, turnIndex);
|
||||
const assistantHash = await store.put(schemas.turn, assistant);
|
||||
turnHashes.push(assistantHash);
|
||||
turnIndex += 1;
|
||||
|
||||
const toolPayloads = loopTurnToToolPayloads(loopTurn, turnIndex);
|
||||
for (const toolPayload of toolPayloads) {
|
||||
const toolHash = await store.put(schemas.turn, toolPayload);
|
||||
turnHashes.push(toolHash);
|
||||
turnIndex += 1;
|
||||
}
|
||||
for (const turn of turns) {
|
||||
const hash = await store.put(schemas.turn, turn);
|
||||
turnHashes.push(hash);
|
||||
}
|
||||
|
||||
const duration = Math.max(0, nowMs - startedAtMs);
|
||||
@@ -110,6 +45,5 @@ export async function storeBuiltinDetail(
|
||||
turns: turnHashes,
|
||||
};
|
||||
const detailHash = await store.put(schemas.detail, detail);
|
||||
const output = extractFinalAssistantText(turns);
|
||||
return { detailHash, output };
|
||||
return { detailHash, turnCount: turnHashes.length };
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
export { createBuiltinAgent } from "./agent.js";
|
||||
export { extractFinalAssistantText, storeBuiltinDetail } from "./detail.js";
|
||||
export { registerBuiltinSchemas, storeBuiltinDetail } from "./detail.js";
|
||||
export type { ChatMessage, LlmAssistantResponse, LlmToolCall } from "./llm/index.js";
|
||||
export { chatCompletionWithTools } from "./llm/index.js";
|
||||
export { BUILTIN_CONTINUE_MAX_TURNS, BUILTIN_MAX_TURNS, runBuiltinLoop } from "./loop.js";
|
||||
export { buildBuiltinMessages } from "./prompt.js";
|
||||
export { appendSessionTurn, initSessionDir, readSessionTurns, removeSession } from "./session.js";
|
||||
export type { BuiltinTool, ToolContext } from "./tools/index.js";
|
||||
export { executeBuiltinTool, getBuiltinTools } from "./tools/index.js";
|
||||
export type {
|
||||
BuiltinDetailPayload,
|
||||
BuiltinLoopTurn,
|
||||
BuiltinSessionState,
|
||||
BuiltinToolCallRecord,
|
||||
BuiltinToolResultRecord,
|
||||
BuiltinTurnPayload,
|
||||
} from "./types.js";
|
||||
|
||||
@@ -96,8 +96,17 @@ function serializeMessage(message: ChatMessage): Record<string, unknown> {
|
||||
export async function chatCompletionWithTools(
|
||||
provider: ResolvedLlmProvider,
|
||||
messages: ChatMessage[],
|
||||
tools: OpenAiToolDefinition[],
|
||||
tools: OpenAiToolDefinition[] | null,
|
||||
): Promise<LlmAssistantResponse> {
|
||||
const body: Record<string, unknown> = {
|
||||
model: provider.model,
|
||||
messages: messages.map(serializeMessage),
|
||||
};
|
||||
if (tools !== null && tools.length > 0) {
|
||||
body.tools = tools;
|
||||
body.tool_choice = "auto";
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(chatUrl(provider.baseUrl), {
|
||||
@@ -106,12 +115,7 @@ export async function chatCompletionWithTools(
|
||||
Authorization: `Bearer ${provider.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: provider.model,
|
||||
messages: messages.map(serializeMessage),
|
||||
tools,
|
||||
tool_choice: "auto",
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
@@ -2,13 +2,14 @@ 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 { BuiltinLoopTurn, BuiltinToolCallRecord, BuiltinToolResultRecord } from "./types.js";
|
||||
import type { BuiltinToolCall, BuiltinTurnPayload } from "./types.js";
|
||||
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
@@ -20,91 +21,190 @@ export type RunBuiltinLoopOptions = {
|
||||
messages: ChatMessage[];
|
||||
toolCtx: ToolContext;
|
||||
maxTurns: number;
|
||||
existingTurns: BuiltinLoopTurn[];
|
||||
storageRoot: string;
|
||||
sessionId: string;
|
||||
/** When true, do not provide tools — force LLM to emit text only. */
|
||||
noTools: boolean;
|
||||
};
|
||||
|
||||
export type RunBuiltinLoopResult = {
|
||||
finalText: string;
|
||||
messages: ChatMessage[];
|
||||
turns: BuiltinLoopTurn[];
|
||||
turnCount: number;
|
||||
};
|
||||
|
||||
function mapToolCalls(calls: LlmToolCall[]): BuiltinToolCallRecord[] {
|
||||
function mapToolCallsForPayload(calls: LlmToolCall[]): BuiltinToolCall[] {
|
||||
return calls.map((call) => ({
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
args: call.arguments,
|
||||
}));
|
||||
}
|
||||
|
||||
async function appendTurn(
|
||||
storageRoot: string,
|
||||
sessionId: string,
|
||||
payload: BuiltinTurnPayload,
|
||||
): Promise<void> {
|
||||
await appendSessionTurn(storageRoot, sessionId, payload);
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export type ShouldNudgeOptions = {
|
||||
noTools: boolean;
|
||||
text: string;
|
||||
turn: number;
|
||||
maxTurns: number;
|
||||
};
|
||||
|
||||
export function shouldNudge({ noTools, text, turn, maxTurns }: ShouldNudgeOptions): boolean {
|
||||
return !noTools && !text.trimStart().startsWith("---") && turn < maxTurns - 1;
|
||||
}
|
||||
|
||||
async function handleTextTurn(
|
||||
text: string,
|
||||
turn: number,
|
||||
noTools: boolean,
|
||||
maxTurns: number,
|
||||
storageRoot: string,
|
||||
sessionId: string,
|
||||
messages: ChatMessage[],
|
||||
): Promise<{ done: boolean; finalText: string }> {
|
||||
await appendTurn(storageRoot, sessionId, {
|
||||
role: "assistant",
|
||||
content: text,
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
|
||||
if (shouldNudge({ noTools, text, turn, maxTurns })) {
|
||||
log("7FXQM2KN", "text-only turn without frontmatter, nudging LLM to continue");
|
||||
const nudge =
|
||||
"You stopped calling tools but your response does not start with the required `---` YAML frontmatter. " +
|
||||
"Either continue using tools to complete your work, or output your final response starting with `---`.";
|
||||
messages.push({ role: "user", content: nudge });
|
||||
return { done: false, finalText: "" };
|
||||
}
|
||||
|
||||
return { done: true, finalText: text };
|
||||
}
|
||||
|
||||
async function handleToolTurn(
|
||||
content: string,
|
||||
toolCalls: LlmToolCall[],
|
||||
toolCtx: ToolContext,
|
||||
messages: ChatMessage[],
|
||||
storageRoot: string,
|
||||
sessionId: string,
|
||||
): Promise<number> {
|
||||
await appendTurn(storageRoot, sessionId, {
|
||||
role: "assistant",
|
||||
content,
|
||||
toolCalls: mapToolCallsForPayload(toolCalls),
|
||||
reasoning: null,
|
||||
});
|
||||
return executeTurnTools(toolCalls, toolCtx, messages, storageRoot, sessionId);
|
||||
}
|
||||
|
||||
export function extractFinalText(messages: ChatMessage[]): string {
|
||||
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() !== ""
|
||||
) {
|
||||
return msg.content;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
type LoopTurnResult = { done: boolean; finalText: string; extraTurns: number };
|
||||
|
||||
async function runLoopTurn(
|
||||
turn: number,
|
||||
options: RunBuiltinLoopOptions,
|
||||
messages: ChatMessage[],
|
||||
openAiTools: ReturnType<typeof builtinToolsToOpenAi>,
|
||||
): Promise<LoopTurnResult> {
|
||||
log("8K2M4N7P", `builtin loop turn ${turn + 1}/${options.maxTurns}`);
|
||||
const response = await chatCompletionWithTools(
|
||||
options.provider,
|
||||
messages,
|
||||
openAiTools.length > 0 ? openAiTools : null,
|
||||
);
|
||||
|
||||
const effectiveToolCalls = options.noTools ? null : (response.toolCalls ?? null);
|
||||
messages.push({ role: "assistant", content: response.content, tool_calls: effectiveToolCalls });
|
||||
|
||||
if (effectiveToolCalls === null || effectiveToolCalls.length === 0) {
|
||||
const text = response.content ?? "";
|
||||
const result = await handleTextTurn(
|
||||
text,
|
||||
turn,
|
||||
options.noTools,
|
||||
options.maxTurns,
|
||||
options.storageRoot,
|
||||
options.sessionId,
|
||||
messages,
|
||||
);
|
||||
return { done: result.done, finalText: result.finalText, extraTurns: 0 };
|
||||
}
|
||||
|
||||
const extra = await handleToolTurn(
|
||||
response.content ?? "",
|
||||
effectiveToolCalls,
|
||||
options.toolCtx,
|
||||
messages,
|
||||
options.storageRoot,
|
||||
options.sessionId,
|
||||
);
|
||||
return { done: false, finalText: "", extraTurns: extra };
|
||||
}
|
||||
|
||||
/** Agent run loop: LLM ↔ tools until no tool_calls or maxTurns. */
|
||||
export async function runBuiltinLoop(
|
||||
options: RunBuiltinLoopOptions,
|
||||
): Promise<RunBuiltinLoopResult> {
|
||||
const messages = [...options.messages];
|
||||
const turns = [...options.existingTurns];
|
||||
const openAiTools = builtinToolsToOpenAi(getBuiltinTools());
|
||||
const openAiTools = options.noTools ? [] : 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 ?? "";
|
||||
turns.push({
|
||||
assistantContent: response.content,
|
||||
toolCalls: null,
|
||||
toolResults: null,
|
||||
});
|
||||
const result = await runLoopTurn(turn, options, messages, openAiTools);
|
||||
turnCount += 1 + result.extraTurns;
|
||||
if (result.done) {
|
||||
finalText = result.finalText;
|
||||
break;
|
||||
}
|
||||
|
||||
const toolCallRecords = mapToolCalls(response.toolCalls);
|
||||
const toolResults: BuiltinToolResultRecord[] = [];
|
||||
|
||||
for (const call of response.toolCalls) {
|
||||
const result = await executeBuiltinTool(call.name, call.arguments, options.toolCtx);
|
||||
toolResults.push({
|
||||
toolCallId: call.id,
|
||||
name: call.name,
|
||||
content: result,
|
||||
});
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: call.id,
|
||||
content: result,
|
||||
});
|
||||
}
|
||||
|
||||
turns.push({
|
||||
assistantContent: response.content,
|
||||
toolCalls: toolCallRecords,
|
||||
toolResults,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
finalText = extractFinalText(messages);
|
||||
}
|
||||
|
||||
return { finalText, messages, turns };
|
||||
return { finalText, messages, turnCount };
|
||||
}
|
||||
|
||||
@@ -59,6 +59,22 @@ export function buildBuiltinMessages(ctx: AgentContext): ChatMessage[] {
|
||||
}
|
||||
systemParts.push(rolePrompt);
|
||||
|
||||
systemParts.push(
|
||||
"",
|
||||
"## Workflow",
|
||||
"",
|
||||
`Your working directory is: ${process.cwd()}`,
|
||||
"",
|
||||
"You have tools available (read_file, write_file, run_command). " +
|
||||
"Use them to complete your task — read files, run commands, make changes as needed. " +
|
||||
"Your task is described in the user message below — do NOT use uwf or workflow CLI commands to discover your task. " +
|
||||
"When you are done, output your final response with the YAML frontmatter block as specified above. " +
|
||||
"Do NOT output the frontmatter until you have completed all necessary work. " +
|
||||
"If you are running low on turns and cannot finish, output the frontmatter with `status: failed` and explain what remains in the body. " +
|
||||
"CRITICAL: Your final output MUST start with the `---` fence on the very first line — " +
|
||||
"no preamble text, no explanation before it. The parser requires `---` at position 0.",
|
||||
);
|
||||
|
||||
const messages: ChatMessage[] = [{ role: "system", content: systemParts.join("\n") }];
|
||||
|
||||
const roleVisitIndices: number[] = [];
|
||||
|
||||
@@ -13,9 +13,8 @@ const BUILTIN_TOOL_CALL_SCHEMA: JSONSchema = {
|
||||
export const BUILTIN_TURN_SCHEMA: JSONSchema = {
|
||||
title: "builtin-turn",
|
||||
type: "object",
|
||||
required: ["index", "role", "content"],
|
||||
required: ["role", "content"],
|
||||
properties: {
|
||||
index: { type: "integer" },
|
||||
role: { type: "string", enum: ["assistant", "tool"] },
|
||||
content: { type: "string" },
|
||||
toolCalls: {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { appendFile, mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import type { BuiltinTurnPayload } from "./types.js";
|
||||
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
function sessionsDir(storageRoot: string): string {
|
||||
return join(storageRoot, "sessions");
|
||||
}
|
||||
|
||||
function sessionFile(storageRoot: string, sessionId: string): string {
|
||||
return join(sessionsDir(storageRoot), `${sessionId}.jsonl`);
|
||||
}
|
||||
|
||||
/** Ensure sessions directory exists. */
|
||||
export async function initSessionDir(storageRoot: string): Promise<void> {
|
||||
await mkdir(sessionsDir(storageRoot), { recursive: true });
|
||||
}
|
||||
|
||||
/** Append a turn to the session jsonl file. */
|
||||
export async function appendSessionTurn(
|
||||
storageRoot: string,
|
||||
sessionId: string,
|
||||
turn: BuiltinTurnPayload,
|
||||
): Promise<void> {
|
||||
const line = `${JSON.stringify(turn)}\n`;
|
||||
await appendFile(sessionFile(storageRoot, sessionId), line, "utf-8");
|
||||
log("3XQVN8KR", `session ${sessionId} appended ${turn.role} turn`);
|
||||
}
|
||||
|
||||
/** Read all turns from session jsonl. Returns empty array if file does not exist. */
|
||||
export async function readSessionTurns(
|
||||
storageRoot: string,
|
||||
sessionId: string,
|
||||
): Promise<BuiltinTurnPayload[]> {
|
||||
try {
|
||||
const content = await readFile(sessionFile(storageRoot, sessionId), "utf-8");
|
||||
const lines = content
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l.length > 0);
|
||||
return lines.map((l) => JSON.parse(l) as BuiltinTurnPayload);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove session jsonl file (called after detail is persisted to step CAS). */
|
||||
export async function removeSession(storageRoot: string, sessionId: string): Promise<void> {
|
||||
try {
|
||||
await rm(sessionFile(storageRoot, sessionId));
|
||||
log("7FWDP2MJ", `session ${sessionId} removed`);
|
||||
} catch {
|
||||
// already gone — fine
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,6 @@ export type BuiltinToolCall = {
|
||||
};
|
||||
|
||||
export type BuiltinTurnPayload = {
|
||||
index: number;
|
||||
role: BuiltinTurnRole;
|
||||
content: string;
|
||||
toolCalls: BuiltinToolCall[] | null;
|
||||
|
||||
@@ -54,7 +54,8 @@ describe("HermesAcpClient", () => {
|
||||
{ timeout: 2 * 60 * 1000 },
|
||||
);
|
||||
|
||||
it(
|
||||
// TODO(#435): flaky — depends on live LLM; mock or move to integration suite
|
||||
it.skip(
|
||||
"prompt() collects structured messages including tool calls",
|
||||
async () => {
|
||||
await client.connect(process.cwd());
|
||||
|
||||
@@ -21,7 +21,8 @@ describe("HermesAcpClient cross-process resume", () => {
|
||||
clients.length = 0;
|
||||
});
|
||||
|
||||
it(
|
||||
// TODO(#435): flaky — depends on live LLM; mock or move to integration suite
|
||||
it.skip(
|
||||
"resume() after close — second prompt returns non-empty text",
|
||||
async () => {
|
||||
// --- Client A: first run ---
|
||||
|
||||
@@ -121,6 +121,11 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
|
||||
let agentResult = await runWithMessage("agent run failed", () => options.run(ctx));
|
||||
|
||||
// Preserve the primary detail from the first run — it contains the full
|
||||
// tool-call turn history. Continuation retries only fix frontmatter
|
||||
// formatting and their 1-turn detail is not meaningful.
|
||||
const primaryDetailHash = agentResult.detailHash;
|
||||
|
||||
// Try to extract frontmatter; retry via continue if it fails
|
||||
let outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
|
||||
@@ -147,7 +152,7 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
const stepHash = await persistStep({
|
||||
ctx,
|
||||
outputHash,
|
||||
detailHash: agentResult.detailHash,
|
||||
detailHash: primaryDetailHash,
|
||||
agentName: agentLabel(options.name),
|
||||
});
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
|
||||
let condName: string | null = null;
|
||||
if (t.condition) {
|
||||
if (expressionToName.has(t.condition)) {
|
||||
condName = expressionToName.get(t.condition)!;
|
||||
condName = expressionToName.get(t.condition) ?? null;
|
||||
} else {
|
||||
condName = `cond${condIdx++}`;
|
||||
expressionToName.set(t.condition, condName);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/noLabelWithoutControl: generic Label component; control association handled by consumer
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
|
||||
@@ -15,6 +15,7 @@ interface State<T, A> {
|
||||
readonly onlyView: boolean;
|
||||
}
|
||||
type Use = <T, A>(sub: SubModel<T, A>) => [T, A];
|
||||
// biome-ignore lint/suspicious/noExplicitAny: UseV intentionally erases the action type
|
||||
type UseV = <T>(sub: SubModel<T, any>) => T;
|
||||
type Create<T, A> = (set: Setter<T>, get: () => T, model: Model) => A;
|
||||
|
||||
@@ -27,7 +28,9 @@ export function generate<T>(val: T) {
|
||||
const next = typeof ch === "function" ? (ch as (prev: T) => T)(val) : ch;
|
||||
if (Object.is(val, next)) return;
|
||||
val = next;
|
||||
listener.forEach((call) => call());
|
||||
for (const call of listener) {
|
||||
call();
|
||||
}
|
||||
}
|
||||
const listen = (call: VoidFunction) => {
|
||||
listener.add(call);
|
||||
@@ -38,21 +41,26 @@ export function generate<T>(val: T) {
|
||||
}
|
||||
|
||||
class SubModel<T, A> {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
_make: () => T,
|
||||
_create: Create<T, A>,
|
||||
_onlyView = false,
|
||||
) {}
|
||||
public readonly name: string;
|
||||
private readonly make: () => T;
|
||||
private readonly create: Create<T, A>;
|
||||
private readonly onlyView: boolean;
|
||||
|
||||
constructor(name: string, _make: () => T, _create: Create<T, A>, _onlyView = false) {
|
||||
this.name = name;
|
||||
this.make = _make;
|
||||
this.create = _create;
|
||||
this.onlyView = _onlyView;
|
||||
}
|
||||
|
||||
public gen(model: Model): State<T, A> {
|
||||
const { make, create, onlyView } = this;
|
||||
const { get, set, use, listen } = generate(make());
|
||||
const actions = create(set, get, model);
|
||||
return { get, set, use, listen, actions, onlyView };
|
||||
const { get, set, use, listen } = generate(this.make());
|
||||
const actions = this.create(set, get, model);
|
||||
return { get, set, use, listen, actions, onlyView: this.onlyView };
|
||||
}
|
||||
|
||||
use(): [T, A] {
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: use() is called as a hook by consumers
|
||||
const { query } = useContext(Context);
|
||||
const { use, actions } = query(this);
|
||||
return [use(), actions];
|
||||
@@ -67,20 +75,27 @@ class SubModel<T, A> {
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: snapshot data is heterogeneous
|
||||
type Snapshot = [name: string, data: any];
|
||||
class Model {
|
||||
private ustack: Snapshot[][] = [];
|
||||
private rstack: Snapshot[][] = [];
|
||||
private transaction = 0;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: backup stores heterogeneous state values
|
||||
private backup = new Map<string, any>();
|
||||
public flow = {} as ReactFlowInstance<AnyWorkNode>;
|
||||
private stackListeners = new Set<() => void>();
|
||||
public readonly stackState: readonly [boolean, boolean] = [false, false];
|
||||
|
||||
constructor(
|
||||
private readonly store: Map<string, State<any, any>>,
|
||||
public readonly use: Use,
|
||||
) {}
|
||||
// biome-ignore lint/suspicious/noExplicitAny: store holds heterogeneous state types
|
||||
private readonly store: Map<string, State<any, any>>;
|
||||
public readonly use: Use;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: store holds heterogeneous state types
|
||||
constructor(store: Map<string, State<any, any>>, use: Use) {
|
||||
this.store = store;
|
||||
this.use = use;
|
||||
}
|
||||
|
||||
public reset() {
|
||||
this.ustack = [];
|
||||
@@ -98,7 +113,9 @@ class Model {
|
||||
private triggerStackState() {
|
||||
// @ts-expect-error
|
||||
this.stackState = [this.canUndo(), this.canRedo()];
|
||||
this.stackListeners.forEach((call) => call());
|
||||
for (const call of this.stackListeners) {
|
||||
call();
|
||||
}
|
||||
}
|
||||
|
||||
private getStackState = () => this.stackState;
|
||||
@@ -108,10 +125,11 @@ class Model {
|
||||
}
|
||||
|
||||
public log() {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: debug log accumulates heterogeneous values
|
||||
const snapshots: Record<string, any> = {};
|
||||
this.store.forEach((state, name) => {
|
||||
for (const [name, state] of this.store) {
|
||||
snapshots[name] = state.get();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public undo() {
|
||||
@@ -119,11 +137,13 @@ class Model {
|
||||
const item = ustack.pop();
|
||||
if (!item) return;
|
||||
const step: Snapshot[] = [];
|
||||
item.forEach(([name, data]) => {
|
||||
const { get, set } = store.get(name)!;
|
||||
for (const [name, data] of item) {
|
||||
const entry = store.get(name);
|
||||
if (!entry) continue;
|
||||
const { get, set } = entry;
|
||||
step.push([name, get()]);
|
||||
set(data);
|
||||
});
|
||||
}
|
||||
rstack.push(step);
|
||||
this.triggerStackState();
|
||||
}
|
||||
@@ -133,11 +153,13 @@ class Model {
|
||||
const item = rstack.pop();
|
||||
if (!item) return;
|
||||
const step: Snapshot[] = [];
|
||||
item.forEach(([name, data]) => {
|
||||
const { get, set } = store.get(name)!;
|
||||
for (const [name, data] of item) {
|
||||
const entry = store.get(name);
|
||||
if (!entry) continue;
|
||||
const { get, set } = entry;
|
||||
step.push([name, get()]);
|
||||
set(data);
|
||||
});
|
||||
}
|
||||
ustack.push(step);
|
||||
this.triggerStackState();
|
||||
}
|
||||
@@ -153,10 +175,10 @@ class Model {
|
||||
public startTransaction() {
|
||||
if (this.transaction === 0) {
|
||||
this.backup.clear();
|
||||
this.store.forEach((state, name) => {
|
||||
if (state.onlyView) return;
|
||||
for (const [name, state] of this.store) {
|
||||
if (state.onlyView) continue;
|
||||
this.backup.set(name, state.get());
|
||||
});
|
||||
}
|
||||
}
|
||||
this.transaction += 1;
|
||||
return this.endTransaction;
|
||||
@@ -167,12 +189,12 @@ class Model {
|
||||
this.transaction -= 1;
|
||||
if (this.transaction === 0) {
|
||||
const changes: Snapshot[] = [];
|
||||
this.store.forEach((state, name) => {
|
||||
if (state.onlyView) return;
|
||||
for (const [name, state] of this.store) {
|
||||
if (state.onlyView) continue;
|
||||
const before = this.backup.get(name);
|
||||
if (Object.is(before, state.get())) return;
|
||||
if (Object.is(before, state.get())) continue;
|
||||
changes.push([name, before]);
|
||||
});
|
||||
}
|
||||
this.backup.clear();
|
||||
if (changes.length === 0) return;
|
||||
this.ustack.push(changes);
|
||||
@@ -183,8 +205,10 @@ class Model {
|
||||
}
|
||||
|
||||
function build() {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: store holds heterogeneous state types
|
||||
const store = new Map<string, State<any, any>>();
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: memo cache stores heterogeneous values
|
||||
const mem: Record<string, any> = {};
|
||||
function use<T, A>(m: SubModel<T, A>): [T, A] {
|
||||
const state = query(m);
|
||||
@@ -231,10 +255,16 @@ function defineModel<T, A>(name: string, make: () => T, create: Create<T, A>) {
|
||||
return new SubModel<T, A>(name, make, create);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: default create returns setter directly
|
||||
const defaultCreate: Create<any, Setter<any>> = (set) => set;
|
||||
function defineView<T, A>(name: string, make: () => T, create: Create<T, A>): SubModel<T, A>;
|
||||
function defineView<T>(name: string, make: () => T): SubModel<T, Setter<T>>;
|
||||
function defineView<T>(name: string, make: () => T, create?: any): any {
|
||||
function defineView<T>(
|
||||
name: string,
|
||||
make: () => T,
|
||||
create?: Create<T, unknown>,
|
||||
): SubModel<T, unknown> {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: wraps into SubModel with erased action type
|
||||
return new SubModel<T, any>(name, make, create ?? defaultCreate, true);
|
||||
}
|
||||
|
||||
@@ -242,9 +272,12 @@ function memoize<T>(init: (use: Use, model: Model) => T) {
|
||||
const id = uuid();
|
||||
return {
|
||||
use(): T {
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: use() is called as a hook by consumers
|
||||
const { mem, model, use } = useContext(Context);
|
||||
const fn = mem[id] || (mem[id] = init(use, model));
|
||||
return fn as T;
|
||||
if (!mem[id]) {
|
||||
mem[id] = init(use, model);
|
||||
}
|
||||
return mem[id] as T;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -253,17 +286,25 @@ function compute<T>(calc: (use: UseV) => T) {
|
||||
const id = uuid();
|
||||
return {
|
||||
use(): T {
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: use() is called as a hook by consumers
|
||||
const { mem, query } = useContext(Context);
|
||||
let state: ReturnType<typeof generate<T>> = mem[id];
|
||||
if (state) return state.use();
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: deps collect heterogeneous SubModels
|
||||
const deps = new Set<SubModel<any, any>>();
|
||||
let usev = (m: SubModel<any, any>) => (deps.add(m), query(m).get());
|
||||
// biome-ignore lint/suspicious/noExplicitAny: useV erases action type
|
||||
let usev = (m: SubModel<any, any>) => {
|
||||
deps.add(m);
|
||||
return query(m).get();
|
||||
};
|
||||
mem[id] = state = generate<T>(calc(usev));
|
||||
if (deps.size) {
|
||||
usev = (m) => query(m).get();
|
||||
const update = () => state.set(calc(usev));
|
||||
deps.forEach((m) => query(m).listen(update));
|
||||
for (const m of deps) {
|
||||
query(m).listen(update);
|
||||
}
|
||||
}
|
||||
return state.use();
|
||||
},
|
||||
|
||||
@@ -137,6 +137,7 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: click handler on badge label */}
|
||||
<div onClick={handleBadgeClick} onKeyDown={undefined} className="cursor-pointer">
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -25,6 +25,7 @@ function Flow() {
|
||||
const readonly = useReadonly();
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: keyboard handler for flow shortcuts
|
||||
<div style={{ height: "100%" }} onKeyDown={readonly ? undefined : handleKeyDown}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow<AnyWorkNode, Edge>
|
||||
|
||||
@@ -12,11 +12,13 @@ interface PrivateEvents {
|
||||
export const InternalField = Symbol("InternalField");
|
||||
|
||||
export class Injection extends Eventer<PrivateEvents> {
|
||||
constructor(
|
||||
public readonly emitPublic: Eventer<PublicEvents>["emit"],
|
||||
private inital_steps?: WorkFlowSteps,
|
||||
) {
|
||||
public readonly emitPublic: Eventer<PublicEvents>["emit"];
|
||||
private inital_steps: WorkFlowSteps | undefined;
|
||||
|
||||
constructor(emitPublic: Eventer<PublicEvents>["emit"], inital_steps?: WorkFlowSteps) {
|
||||
super();
|
||||
this.emitPublic = emitPublic;
|
||||
this.inital_steps = inital_steps;
|
||||
}
|
||||
|
||||
public on: Eventer<PrivateEvents>["on"] = (type, lisenter) => {
|
||||
|
||||
@@ -60,8 +60,8 @@ function assignLayers(nodes: Node[], edges: Edge[]): Map<string, number> {
|
||||
|
||||
// 2. BFS 分层(排除 end 节点,稍后单独处理)
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const currentLayer = layers.get(current)!;
|
||||
const current = queue.shift() ?? "";
|
||||
const currentLayer = layers.get(current) ?? 0;
|
||||
|
||||
for (const target of outgoing.get(current) ?? []) {
|
||||
// 跳过 end 节点,稍后处理
|
||||
|
||||
@@ -31,6 +31,7 @@ export const editNodeViewModel = define.view("editNodeView", editNodeView, (set,
|
||||
|
||||
model.startTransaction();
|
||||
editNode(state.node.id, (node) => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: node data type varies by node kind
|
||||
node.data = data as any;
|
||||
});
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
|
||||
@@ -23,6 +23,7 @@ export const handlers = define.memoize((use, model) => {
|
||||
if (!to || !fromHandle || !fromNode) return;
|
||||
const { clientX, clientY } = event as MouseEvent;
|
||||
use(addNodeViewModel)[1].start({
|
||||
// biome-ignore lint/suspicious/noExplicitAny: ReactFlow node type mismatch
|
||||
fromNode: fromNode as any as AnyWorkNode,
|
||||
fromHandle: fromHandle,
|
||||
position: model.flow.screenToFlowPosition({ x: clientX, y: clientY }),
|
||||
|
||||
@@ -67,7 +67,7 @@ export function transIn(steps: WorkFlowStep[]): Result {
|
||||
});
|
||||
}
|
||||
|
||||
const firstStepId = nameToId.get(steps[0].role.name)!;
|
||||
const firstStepId = nameToId.get(steps[0].role.name) ?? "";
|
||||
edges.push({
|
||||
id: `e-start-${firstStepId}`,
|
||||
source: "start",
|
||||
@@ -78,8 +78,8 @@ export function transIn(steps: WorkFlowStep[]): Result {
|
||||
});
|
||||
|
||||
for (const step of steps) {
|
||||
const sourceId = nameToId.get(step.role.name)!;
|
||||
const _sourceOrder = idToOrder.get(sourceId)!;
|
||||
const sourceId = nameToId.get(step.role.name) ?? "";
|
||||
const _sourceOrder = idToOrder.get(sourceId) ?? 0;
|
||||
const hasMultipleTransitions = step.transitions.length > 1;
|
||||
|
||||
const sorted = hasMultipleTransitions
|
||||
|
||||
@@ -169,7 +169,7 @@ function bfs(startId: string, adj: Map<string, string[]>): Set<string> {
|
||||
const queue = [startId];
|
||||
visited.add(startId);
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const current = queue.shift() ?? "";
|
||||
for (const next of adj.get(current) ?? []) {
|
||||
if (!visited.has(next)) {
|
||||
visited.add(next);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
interface Maper<T> {
|
||||
type Maper<T> = {
|
||||
[key: string]: T;
|
||||
}
|
||||
};
|
||||
type Listen<T> = (data: T) => void;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: generic event map requires any
|
||||
export class Eventer<M extends Maper<any>> {
|
||||
// biome-ignore lint/complexity/noBannedTypes: Set<Function> needed for heterogeneous listener types
|
||||
private lisenters = {} as { [K in keyof M]: Set<Function> };
|
||||
|
||||
public on<K extends keyof M>(key: K, lisenter: Listen<M[K]>) {
|
||||
@@ -28,6 +30,8 @@ export class Eventer<M extends Maper<any>> {
|
||||
const set = this.lisenters[key];
|
||||
if (set === undefined) return;
|
||||
// Todo: maybe implement stoping bubble
|
||||
set.forEach((call) => call(data));
|
||||
for (const call of set) {
|
||||
call(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
--font-sans: "Geist Variable", sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
|
||||
@@ -55,7 +55,7 @@ export function DetailPage(): ReactNode {
|
||||
);
|
||||
}
|
||||
|
||||
const basePath = `/workflow/${encodeURIComponent(name!)}`;
|
||||
const basePath = `/workflow/${encodeURIComponent(name ?? "")}`;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
|
||||
Reference in New Issue
Block a user