From 45f479e60fe12af3ce8e265441bb6260fd541a5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 25 May 2026 08:01:50 +0000 Subject: [PATCH] feat(protocol): add step-level timing (startedAtMs / completedAtMs) (#489) BREAKING CHANGE: StepRecord now requires startedAtMs and completedAtMs fields. StepEntry now requires durationMs field. Old CAS data without these fields is invalid. - Add startedAtMs/completedAtMs to StepRecord and StepNodePayload - Add durationMs to StepEntry (computed: completedAtMs - startedAtMs) - Update STEP_NODE_SCHEMA to require timing fields as integers - Record Date.now() before/after agent execution in createAgent - Show duration in thread read headers (formatStepHeader) - Update existing test fixtures with timing fields --- .../src/__tests__/step-read.test.ts | 12 + .../src/__tests__/step-timing.test.ts | 378 ++++++++++++++++++ .../src/__tests__/thread-read-quota.test.ts | 18 +- .../__tests__/thread-read-xml-tags.test.ts | 24 ++ packages/cli-workflow/src/commands/step.ts | 1 + packages/cli-workflow/src/commands/thread.ts | 13 +- packages/workflow-agent-kit/src/context.ts | 2 + packages/workflow-agent-kit/src/run.ts | 13 +- packages/workflow-protocol/src/schemas.ts | 4 +- packages/workflow-protocol/src/types.ts | 5 + 10 files changed, 465 insertions(+), 5 deletions(-) create mode 100644 packages/cli-workflow/src/__tests__/step-timing.test.ts diff --git a/packages/cli-workflow/src/__tests__/step-read.test.ts b/packages/cli-workflow/src/__tests__/step-read.test.ts index 61d17ec..e590d70 100644 --- a/packages/cli-workflow/src/__tests__/step-read.test.ts +++ b/packages/cli-workflow/src/__tests__/step-read.test.ts @@ -144,6 +144,8 @@ describe("step read", () => { output: outputHash, detail: detailHash, agent: "uwf-test", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); // Read step with large quota @@ -227,6 +229,8 @@ describe("step read", () => { output: outputHash, detail: detailHash, agent: "uwf-test", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); // Read step with limited quota (700 chars) @@ -304,6 +308,8 @@ describe("step read", () => { output: outputHash, detail: detailHash, agent: "uwf-test", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); // Read step with minimal quota (1 char) @@ -357,6 +363,8 @@ describe("step read", () => { output: outputHash, detail: null, agent: "uwf-test", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); // Read step - should return metadata only (no error) @@ -431,6 +439,8 @@ describe("step read", () => { output: outputHash, detail: detailHash, agent: "uwf-test", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); // Read step - should return metadata only (no error) @@ -505,6 +515,8 @@ describe("step read", () => { output: outputHash, detail: detailHash, agent: "uwf-test", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); // Read step diff --git a/packages/cli-workflow/src/__tests__/step-timing.test.ts b/packages/cli-workflow/src/__tests__/step-timing.test.ts new file mode 100644 index 0000000..37970b0 --- /dev/null +++ b/packages/cli-workflow/src/__tests__/step-timing.test.ts @@ -0,0 +1,378 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { bootstrap, putSchema } from "@uncaged/json-cas"; +import { createFsStore } from "@uncaged/json-cas-fs"; +import type { CasRef, ThreadId } from "@uncaged/workflow-protocol"; +import { STEP_NODE_SCHEMA } from "@uncaged/workflow-protocol"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { cmdStepList } from "../commands/step.js"; +import { cmdThreadRead } from "../commands/thread.js"; +import { registerUwfSchemas } from "../schemas.js"; +import { saveThreadsIndex } from "../store.js"; + +// ── schemas ────────────────────────────────────────────────────────────────── + +const TURN_SCHEMA = { + title: "hermes-turn", + type: "object" as const, + required: ["index", "role", "content"], + properties: { + index: { type: "integer" as const }, + role: { type: "string" as const }, + content: { type: "string" as const }, + toolCalls: { + anyOf: [ + { type: "array" as const, items: { type: "object" as const } }, + { type: "null" as const }, + ], + }, + reasoning: { anyOf: [{ type: "string" as const }, { type: "null" as const }] }, + }, + additionalProperties: false, +}; + +const DETAIL_SCHEMA = { + title: "hermes-detail", + type: "object" as const, + required: ["sessionId", "model", "duration", "turnCount", "turns"], + properties: { + sessionId: { type: "string" as const }, + model: { type: "string" as const }, + duration: { type: "integer" as const }, + turnCount: { type: "integer" as const }, + turns: { + type: "array" as const, + items: { type: "string" as const, format: "cas_ref" }, + }, + }, + additionalProperties: false, +}; + +// ── helpers ────────────────────────────────────────────────────────────────── + +async function registerDetailSchemas(store: ReturnType) { + await bootstrap(store); + const [turn, detail] = await Promise.all([ + putSchema(store, TURN_SCHEMA), + putSchema(store, DETAIL_SCHEMA), + ]); + return { turn, detail }; +} + +// ── fixture ────────────────────────────────────────────────────────────────── + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-timing-test-")); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +// ── 1. Protocol types (compile-time) ───────────────────────────────────────── + +describe("protocol types", () => { + test("StepRecord has startedAtMs and completedAtMs as required fields", () => { + // Type-level test: this block compiles only if fields exist and are number + const record: import("@uncaged/workflow-protocol").StepRecord = { + role: "test", + output: "hash1" as CasRef, + detail: "hash2" as CasRef, + agent: "uwf-test", + edgePrompt: "", + startedAtMs: 1000, + completedAtMs: 2000, + }; + expect(record.startedAtMs).toBe(1000); + expect(record.completedAtMs).toBe(2000); + }); + + test("StepEntry has durationMs as required field", () => { + const entry: import("@uncaged/workflow-protocol").StepEntry = { + hash: "hash" as CasRef, + role: "test", + output: {}, + detail: "hash2" as CasRef, + agent: "uwf-test", + timestamp: 123, + durationMs: 5000, + }; + expect(entry.durationMs).toBe(5000); + }); +}); + +// ── 2. JSON Schema ─────────────────────────────────────────────────────────── + +describe("StepNode JSON schema", () => { + test("schema requires startedAtMs and completedAtMs", () => { + const required = STEP_NODE_SCHEMA.required as string[]; + expect(required).toContain("startedAtMs"); + expect(required).toContain("completedAtMs"); + }); + + test("schema defines timing fields as integer", () => { + const props = STEP_NODE_SCHEMA.properties as Record; + expect(props.startedAtMs.type).toBe("integer"); + expect(props.completedAtMs.type).toBe("integer"); + }); + + test("StepNode with timing fields passes CAS validation", async () => { + const casDir = join(tmpDir, "cas"); + await mkdir(casDir, { recursive: true }); + const store = createFsStore(casDir); + const schemas = await registerUwfSchemas(store); + + const startHash = await store.put(schemas.startNode, { + workflow: "placeholder0000" as CasRef, + prompt: "test", + }); + + const outputHash = await store.put(schemas.text, "output text"); + + const detailSchemas = await registerDetailSchemas(store); + const detailHash = await store.put(detailSchemas.detail, { + sessionId: "s1", + model: "m1", + duration: 100, + turnCount: 0, + turns: [], + }); + + // Should succeed — valid timing fields + const hash = await store.put(schemas.stepNode, { + start: startHash, + prev: null, + role: "worker", + output: outputHash, + detail: detailHash, + agent: "uwf-test", + edgePrompt: "", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, + }); + expect(hash).toBeTruthy(); + }); +}); + +// ── 3. step list — durationMs computed ─────────────────────────────────────── + +describe("step list timing", () => { + test("step list includes durationMs = completedAtMs - startedAtMs", async () => { + const casDir = join(tmpDir, "cas"); + await mkdir(casDir, { recursive: true }); + const store = createFsStore(casDir); + const schemas = await registerUwfSchemas(store); + const detailSchemas = await registerDetailSchemas(store); + + const workflowHash = await store.put(schemas.workflow, { + name: "test-wf", + description: "desc", + roles: {}, + graph: {}, + }); + + const startHash = await store.put(schemas.startNode, { + workflow: workflowHash, + prompt: "test", + }); + + const outputHash = await store.put(schemas.text, "output"); + const detailHash = await store.put(detailSchemas.detail, { + sessionId: "s1", + model: "m1", + duration: 100, + turnCount: 0, + turns: [], + }); + + const startedAt = 1716600000000; + const completedAt = 1716600003500; + + const stepHash = await store.put(schemas.stepNode, { + start: startHash, + prev: null, + role: "worker", + output: outputHash, + detail: detailHash, + agent: "uwf-test", + edgePrompt: "", + startedAtMs: startedAt, + completedAtMs: completedAt, + }); + + const threadId = "01HX2Q3R4S5T6V7W8X9YZ1" as ThreadId; + await saveThreadsIndex(tmpDir, { [threadId]: stepHash }); + + const result = await cmdStepList(tmpDir, threadId); + const stepEntries = result.steps.slice(1); // skip start entry + expect(stepEntries).toHaveLength(1); + + const step = stepEntries[0] as import("@uncaged/workflow-protocol").StepEntry; + expect(step.durationMs).toBe(3500); + }); +}); + +// ── 4. thread read — duration in header ────────────────────────────────────── + +describe("thread read timing", () => { + test("thread read header includes Duration", async () => { + const casDir = join(tmpDir, "cas"); + await mkdir(casDir, { recursive: true }); + const store = createFsStore(casDir); + const schemas = await registerUwfSchemas(store); + const detailSchemas = await registerDetailSchemas(store); + + const workflowHash = await store.put(schemas.workflow, { + name: "test-wf", + description: "desc", + roles: { + worker: { + description: "Worker", + goal: "Do work", + capabilities: [], + procedure: "work", + output: "result", + frontmatter: "placeholder0000" as CasRef, + }, + }, + graph: { + $START: { _: { role: "worker", prompt: "go" } }, + worker: { _: { role: "$END", prompt: "" } }, + }, + }); + + const startHash = await store.put(schemas.startNode, { + workflow: workflowHash, + prompt: "test task", + }); + + const turnHash = await store.put(detailSchemas.turn, { + index: 0, + role: "assistant", + content: "Done.", + toolCalls: null, + reasoning: null, + }); + const detailHash = await store.put(detailSchemas.detail, { + sessionId: "s1", + model: "m1", + duration: 100, + turnCount: 1, + turns: [turnHash], + }); + const outputHash = await store.put(schemas.text, "output"); + + const stepHash = await store.put(schemas.stepNode, { + start: startHash, + prev: null, + role: "worker", + output: outputHash, + detail: detailHash, + agent: "uwf-test", + edgePrompt: "", + startedAtMs: 1716600000000, + completedAtMs: 1716600042000, + }); + + const threadId = "01HX2Q3R4S5T6V7W8X9YZ3" as ThreadId; + await saveThreadsIndex(tmpDir, { [threadId]: stepHash }); + + const markdown = await cmdThreadRead(tmpDir, threadId, 10000, null, false); + expect(markdown).toContain("**Duration:** 42.0s"); + }); + + test("thread read shows sub-second duration as ms", async () => { + const casDir = join(tmpDir, "cas"); + await mkdir(casDir, { recursive: true }); + const store = createFsStore(casDir); + const schemas = await registerUwfSchemas(store); + const detailSchemas = await registerDetailSchemas(store); + + const workflowHash = await store.put(schemas.workflow, { + name: "test-wf", + description: "desc", + roles: { + worker: { + description: "Worker", + goal: "Do work", + capabilities: [], + procedure: "work", + output: "result", + frontmatter: "placeholder0000" as CasRef, + }, + }, + graph: { + $START: { _: { role: "worker", prompt: "go" } }, + worker: { _: { role: "$END", prompt: "" } }, + }, + }); + + const startHash = await store.put(schemas.startNode, { + workflow: workflowHash, + prompt: "test", + }); + + const turnHash = await store.put(detailSchemas.turn, { + index: 0, + role: "assistant", + content: "Done.", + toolCalls: null, + reasoning: null, + }); + const detailHash = await store.put(detailSchemas.detail, { + sessionId: "s1", + model: "m1", + duration: 100, + turnCount: 1, + turns: [turnHash], + }); + const outputHash = await store.put(schemas.text, "output"); + + const stepHash = await store.put(schemas.stepNode, { + start: startHash, + prev: null, + role: "worker", + output: outputHash, + detail: detailHash, + agent: "uwf-test", + edgePrompt: "", + startedAtMs: 1716600000000, + completedAtMs: 1716600000350, + }); + + const threadId = "01HX2Q3R4S5T6V7W8X9YZ4" as ThreadId; + await saveThreadsIndex(tmpDir, { [threadId]: stepHash }); + + const markdown = await cmdThreadRead(tmpDir, threadId, 10000, null, false); + expect(markdown).toContain("**Duration:** 350ms"); + }); +}); + +// ── 6. Breaking change — old data without timing fails ─────────────────────── + +describe("breaking change", () => { + test("StepNode schema rejects payload without timing fields", () => { + const required = STEP_NODE_SCHEMA.required as string[]; + // Both fields must be in the required array + expect(required).toContain("startedAtMs"); + expect(required).toContain("completedAtMs"); + + // Payload without timing fields would fail schema validation + // because the schema marks them as required + const payloadWithoutTiming = { + start: "hash1", + prev: null, + role: "worker", + output: "hash2", + detail: "hash3", + agent: "uwf-test", + edgePrompt: "", + }; + // Verify the payload is missing required fields + expect(payloadWithoutTiming).not.toHaveProperty("startedAtMs"); + expect(payloadWithoutTiming).not.toHaveProperty("completedAtMs"); + }); +}); diff --git a/packages/cli-workflow/src/__tests__/thread-read-quota.test.ts b/packages/cli-workflow/src/__tests__/thread-read-quota.test.ts index 2795945..8f6c332 100644 --- a/packages/cli-workflow/src/__tests__/thread-read-quota.test.ts +++ b/packages/cli-workflow/src/__tests__/thread-read-quota.test.ts @@ -141,6 +141,8 @@ describe("thread read --quota flag", () => { output: outputHash, detail: detailHash, agent: "uwf-test", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); steps.push(stepHash); } @@ -221,6 +223,8 @@ describe("thread read --quota flag", () => { output: outputHash, detail: step1DetailHash, agent: "uwf-test", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); const step2Content = generateContent(600, "Second"); @@ -245,6 +249,8 @@ describe("thread read --quota flag", () => { output: outputHash, detail: step2DetailHash, agent: "uwf-test", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); const threadId = "01HX2Q3R4S5T6V7W8X9YZ1" as ThreadId; @@ -328,6 +334,8 @@ describe("thread read --quota flag", () => { output: outputHash, detail: detailHash, agent: "uwf-test", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); steps.push(stepHash); } @@ -338,8 +346,8 @@ describe("thread read --quota flag", () => { // Set tight quota with --start flag const markdown = await cmdThreadRead(tmpDir, threadId, 600, null, true); - // Quota must be reasonably enforced (allow ~210 char tolerance for structure) - expect(markdown.length).toBeLessThanOrEqual(810); + // Quota must be reasonably enforced (allow ~260 char tolerance for structure) + expect(markdown.length).toBeLessThanOrEqual(860); // Should contain thread header expect(markdown).toMatch(/# Thread/); @@ -405,6 +413,8 @@ describe("thread read --quota flag", () => { output: outputHash, detail: detailHash, agent: "uwf-test", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); const threadId = "01HX2Q3R4S5T6V7W8X9YZ4" as ThreadId; @@ -480,6 +490,8 @@ describe("thread read --quota flag", () => { output: outputHash, detail: detailHash, agent: "uwf-test", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); steps.push(stepHash); } @@ -559,6 +571,8 @@ describe("thread read --quota flag", () => { output: outputHash, detail: detailHash, agent: "uwf-test", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); steps.push(stepHash); } diff --git a/packages/cli-workflow/src/__tests__/thread-read-xml-tags.test.ts b/packages/cli-workflow/src/__tests__/thread-read-xml-tags.test.ts index 86023c8..67346e5 100644 --- a/packages/cli-workflow/src/__tests__/thread-read-xml-tags.test.ts +++ b/packages/cli-workflow/src/__tests__/thread-read-xml-tags.test.ts @@ -139,6 +139,8 @@ describe("thread read XML tag isolation", () => { output: outputHash, detail: detailHash, agent: "uwf-claude-code", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); const threadId = "01JTEST0000000000000001" as ThreadId; @@ -214,6 +216,8 @@ describe("thread read XML tag isolation", () => { output: outputHash, detail: detailHash, agent: "uwf-claude-code", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); const threadId = "01JTEST0000000000000002" as ThreadId; @@ -274,6 +278,8 @@ describe("thread read XML tag isolation", () => { output: outputHash, detail: null, agent: "uwf-test", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); const step2 = await uwf.store.put(uwf.schemas.stepNode, { @@ -283,6 +289,8 @@ describe("thread read XML tag isolation", () => { output: outputHash, detail: null, agent: "uwf-test", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); const threadId = "01JTEST0000000000000003" as ThreadId; @@ -335,6 +343,8 @@ describe("thread read XML tag isolation", () => { output: outputHash, detail: null, agent: "uwf-test", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); const threadId = "01JTEST0000000000000004" as ThreadId; @@ -387,6 +397,8 @@ describe("thread read XML tag isolation", () => { output: outputHash, detail: missingDetailRef, agent: "uwf-test", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); const threadId = "01JTEST0000000000000005" as ThreadId; @@ -439,6 +451,8 @@ describe("thread read XML tag isolation", () => { output: outputHash, detail: null, agent: "uwf-test", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); const threadId = "01JTEST0000000000000006" as ThreadId; @@ -511,6 +525,8 @@ describe("thread read XML tag isolation", () => { output: outputHash, detail: null, agent: "uwf-test", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); const step2 = await uwf.store.put(uwf.schemas.stepNode, { @@ -520,6 +536,8 @@ describe("thread read XML tag isolation", () => { output: outputHash, detail: null, agent: "uwf-test", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); const step3 = await uwf.store.put(uwf.schemas.stepNode, { @@ -529,6 +547,8 @@ describe("thread read XML tag isolation", () => { output: outputHash, detail: null, agent: "uwf-test", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); const threadId = "01JTEST0000000000000007" as ThreadId; @@ -607,6 +627,8 @@ describe("thread read XML tag isolation", () => { output: outputHash, detail: detailHash, agent: "uwf-test", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, }); const threadId = "01JTEST0000000000000008" as ThreadId; @@ -661,6 +683,8 @@ describe("thread read XML tag isolation", () => { output: outputHash, detail: null, agent: "uwf-test", + startedAtMs: 1000000000000, + completedAtMs: 1000000005000, })) as CasRef; steps.push(step); prev = step; diff --git a/packages/cli-workflow/src/commands/step.ts b/packages/cli-workflow/src/commands/step.ts index 893a946..4c74475 100644 --- a/packages/cli-workflow/src/commands/step.ts +++ b/packages/cli-workflow/src/commands/step.ts @@ -58,6 +58,7 @@ export async function cmdStepList( detail: item.payload.detail ?? null, agent: item.payload.agent, timestamp: item.timestamp, + durationMs: item.payload.completedAtMs - item.payload.startedAtMs, }); } diff --git a/packages/cli-workflow/src/commands/thread.ts b/packages/cli-workflow/src/commands/thread.ts index 55d9941..eafa3f3 100644 --- a/packages/cli-workflow/src/commands/thread.ts +++ b/packages/cli-workflow/src/commands/thread.ts @@ -566,14 +566,25 @@ function selectByQuota( return { selected, skippedCount: candidates.length - selected.length }; } +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const seconds = ms / 1000; + if (seconds < 60) return `${seconds.toFixed(1)}s`; + const minutes = Math.floor(seconds / 60); + const remainingSec = Math.round(seconds % 60); + return `${minutes}m${remainingSec}s`; +} + function formatStepHeader(stepNum: number, item: OrderedStepItem): string { const ts = new Date(item.timestamp) .toISOString() .replace("T", " ") .replace(/\.\d+Z$/, ""); + const durationMs = item.payload.completedAtMs - item.payload.startedAtMs; + const duration = formatDuration(durationMs); return [ `## Step ${stepNum}: ${item.payload.role} \`${item.hash}\``, - `**Agent:** ${item.payload.agent} | **Time:** ${ts}`, + `**Agent:** ${item.payload.agent} | **Time:** ${ts} | **Duration:** ${duration}`, ].join("\n"); } diff --git a/packages/workflow-agent-kit/src/context.ts b/packages/workflow-agent-kit/src/context.ts index 2d21250..4f306d7 100644 --- a/packages/workflow-agent-kit/src/context.ts +++ b/packages/workflow-agent-kit/src/context.ts @@ -128,6 +128,8 @@ async function buildHistory( detail: step.detail, agent: step.agent, edgePrompt: step.edgePrompt ?? "", + startedAtMs: step.startedAtMs, + completedAtMs: step.completedAtMs, content, }); } diff --git a/packages/workflow-agent-kit/src/run.ts b/packages/workflow-agent-kit/src/run.ts index 391e3f8..b930cec 100644 --- a/packages/workflow-agent-kit/src/run.ts +++ b/packages/workflow-agent-kit/src/run.ts @@ -59,6 +59,8 @@ async function writeStepNode(options: { detailHash: CasRef; agentName: string; edgePrompt: string; + startedAtMs: number; + completedAtMs: number; }): Promise { const payload: StepNodePayload = { start: options.startHash, @@ -68,6 +70,8 @@ async function writeStepNode(options: { detail: options.detailHash, agent: options.agentName, edgePrompt: options.edgePrompt, + startedAtMs: options.startedAtMs, + completedAtMs: options.completedAtMs, }; const hash = await options.store.put(options.schemas.stepNode, payload); const node = options.store.get(hash); @@ -94,6 +98,8 @@ async function persistStep(options: { outputHash: CasRef; detailHash: CasRef; agentName: string; + startedAtMs: number; + completedAtMs: number; }): Promise { const { store, schemas, chain, headHash } = options.ctx.meta; return writeStepNode({ @@ -106,6 +112,8 @@ async function persistStep(options: { detailHash: options.detailHash, agentName: options.agentName, edgePrompt: options.ctx.edgePrompt, + startedAtMs: options.startedAtMs, + completedAtMs: options.completedAtMs, }); } @@ -127,6 +135,7 @@ export function createAgent(options: AgentOptions): () => Promise { ctx.outputFormatInstruction = buildOutputFormatInstruction(frontmatterSchema); } + const startedAtMs = Date.now(); let agentResult = await runWithMessage("agent run failed", () => options.run(ctx)); // Preserve the primary detail from the first run — it contains the full @@ -156,12 +165,14 @@ export function createAgent(options: AgentOptions): () => Promise { `Raw output (first 500 chars): ${agentResult.output.slice(0, 500)}`, ); } - + const completedAtMs = Date.now(); const stepHash = await persistStep({ ctx, outputHash, detailHash: primaryDetailHash, agentName: agentLabel(options.name), + startedAtMs, + completedAtMs, }); process.stdout.write(`${stepHash}\n`); diff --git a/packages/workflow-protocol/src/schemas.ts b/packages/workflow-protocol/src/schemas.ts index 77aed81..252b6c1 100644 --- a/packages/workflow-protocol/src/schemas.ts +++ b/packages/workflow-protocol/src/schemas.ts @@ -60,7 +60,7 @@ export const START_NODE_SCHEMA: JSONSchema = { export const STEP_NODE_SCHEMA: JSONSchema = { title: "StepNode", type: "object", - required: ["start", "prev", "role", "output", "detail", "agent"], + required: ["start", "prev", "role", "output", "detail", "agent", "startedAtMs", "completedAtMs"], properties: { start: { type: "string", format: "cas_ref" }, prev: { @@ -71,6 +71,8 @@ export const STEP_NODE_SCHEMA: JSONSchema = { detail: { type: "string", format: "cas_ref" }, agent: { type: "string" }, edgePrompt: { type: "string" }, + startedAtMs: { type: "integer" }, + completedAtMs: { type: "integer" }, }, additionalProperties: false, }; diff --git a/packages/workflow-protocol/src/types.ts b/packages/workflow-protocol/src/types.ts index 28252db..aa6d5c1 100644 --- a/packages/workflow-protocol/src/types.ts +++ b/packages/workflow-protocol/src/types.ts @@ -14,6 +14,10 @@ export type StepRecord = { agent: string; /** Moderator edge prompt that led to this step. Missing in legacy nodes → "". */ edgePrompt: string; + /** Date.now() before agent spawn */ + startedAtMs: number; + /** Date.now() after agent returns */ + completedAtMs: number; }; // ── 4.2 Workflow 定义 ─────────────────────────────────────────────── @@ -89,6 +93,7 @@ export type StepEntry = { detail: CasRef; agent: string; timestamp: number; + durationMs: number; }; /** uwf thread steps — start entry */ -- 2.43.0