@poncho-ai/harness 0.60.1 → 0.61.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import type { AgentEvent, Message } from "@poncho-ai/sdk";
2
3
  import type { Conversation } from "../state.js";
3
4
  import type { AgentHarness } from "../harness.js";
@@ -341,6 +342,110 @@ export const buildApprovalCheckpoints = ({
341
342
  kind,
342
343
  }));
343
344
 
345
+ // ── Checkpoint transcript reconstruction (single source of truth) ──
346
+ //
347
+ // Resuming a checkpointed turn requires rebuilding the FULL canonical model
348
+ // transcript from what was stored at the pause. Getting this right — in the
349
+ // canonical message space, not the display one — is subtle, and re-deriving
350
+ // it by hand in each embedder is exactly how the model-facing transcript
351
+ // drifted from the display transcript and silently dropped a turn's user
352
+ // message after an approval. These helpers are that logic, exported so the
353
+ // orchestrator's own resume AND external embedders (e.g. PonchOS, which
354
+ // executes gated tools itself) share one implementation that can't drift.
355
+
356
+ /** Reconstruct the full canonical transcript a checkpoint resumes from: the
357
+ * prior history (before the checkpointed turn) + the checkpoint delta.
358
+ * Handles both storage conventions via `normalizeApprovalCheckpoint` — the
359
+ * initial checkpoint (base = prior length, delta) and a resume-created one
360
+ * (base 0, full canonical). */
361
+ export const assembleCheckpointMessages = (
362
+ conversation: Conversation,
363
+ checkpoint: StoredApproval,
364
+ ): Message[] => {
365
+ const n = normalizeApprovalCheckpoint(checkpoint, conversation.messages);
366
+ const base = n.baseMessageCount != null ? conversation.messages.slice(0, n.baseMessageCount) : [];
367
+ return [...base, ...(n.checkpointMessages ?? [])];
368
+ };
369
+
370
+ /** Pair an assistant tool-call message's `tool_calls` with externally-computed
371
+ * results into the single `role:"tool"` message a continuation reads.
372
+ * Returns undefined when `assistantMsg` isn't an assistant message with
373
+ * parseable tool calls. Used to persist the resume's canonical history so it
374
+ * matches what `continueFromToolResult` fed the model. Missing results
375
+ * default to the deferred-error marker (same text the run uses). */
376
+ export const buildToolResultMessage = (
377
+ assistantMsg: Message | undefined,
378
+ toolResults: Array<{ callId: string; toolName: string; result?: unknown; error?: string }>,
379
+ ): Message | undefined => {
380
+ if (assistantMsg?.role !== "assistant") return undefined;
381
+ let toolCalls: Array<{ id: string; name: string }> = [];
382
+ try {
383
+ const parsed = JSON.parse(typeof assistantMsg.content === "string" ? assistantMsg.content : "");
384
+ toolCalls = parsed.tool_calls ?? [];
385
+ } catch {
386
+ return undefined;
387
+ }
388
+ if (toolCalls.length === 0) return undefined;
389
+ const provided = new Map(toolResults.map((r) => [r.callId, r]));
390
+ return {
391
+ role: "tool",
392
+ content: JSON.stringify(
393
+ toolCalls.map((tc) => {
394
+ const r = provided.get(tc.id);
395
+ return {
396
+ type: "tool_result",
397
+ tool_use_id: tc.id,
398
+ tool_name: r?.toolName ?? tc.name,
399
+ content: r
400
+ ? (r.error ? `Tool error: ${r.error}` : JSON.stringify(r.result ?? null))
401
+ : "Tool error: Tool execution deferred (pending approval checkpoint)",
402
+ };
403
+ }),
404
+ ),
405
+ metadata: { timestamp: Date.now(), id: randomUUID() },
406
+ };
407
+ };
408
+
409
+ /** Build the `pendingApprovals` rows for a checkpoint reached DURING a resume
410
+ * continuation. Stores the WHOLE canonical history the continuation ran with
411
+ * (`priorMessages` = prior + tool result + new delta) with
412
+ * `baseMessageCount: 0`. Keeping the full history here — rather than a delta
413
+ * + a base count into a different message array — is what lets the next
414
+ * resume reconstruct with no index arithmetic. */
415
+ export const buildResumeCheckpoints = ({
416
+ priorMessages,
417
+ checkpointEvent,
418
+ runId,
419
+ kind = "approval",
420
+ }: {
421
+ priorMessages: Message[];
422
+ checkpointEvent: {
423
+ approvals: ApprovalEventItem[];
424
+ checkpointMessages: Message[];
425
+ pendingToolCalls: PendingToolCall[];
426
+ };
427
+ runId: string;
428
+ kind?: "approval" | "device";
429
+ }): NonNullable<Conversation["pendingApprovals"]> =>
430
+ buildApprovalCheckpoints({
431
+ approvals: checkpointEvent.approvals,
432
+ runId,
433
+ checkpointMessages: [...priorMessages, ...checkpointEvent.checkpointMessages],
434
+ baseMessageCount: 0,
435
+ pendingToolCalls: checkpointEvent.pendingToolCalls,
436
+ kind,
437
+ });
438
+
439
+ /** Text of a message for the transcript-integrity guard — flattens the two
440
+ * content shapes so a user message can be matched across the display and
441
+ * canonical transcripts regardless of how each stored it. */
442
+ const messageText = (m: Message): string =>
443
+ typeof m.content === "string"
444
+ ? m.content
445
+ : Array.isArray(m.content)
446
+ ? m.content.map((p) => (p as { text?: string }).text ?? "").join("")
447
+ : "";
448
+
344
449
  // ── Turn metadata persistence ──
345
450
 
346
451
  export const applyTurnMetadata = (
@@ -373,6 +478,31 @@ export const applyTurnMetadata = (
373
478
  conv._harnessMessages = conv.messages;
374
479
  }
375
480
 
481
+ // Invariant guard: the model-facing transcript must not drop the current
482
+ // turn's user message. A resume that reconstructed `_harnessMessages`
483
+ // incorrectly silently lost it (the display transcript kept it, the model
484
+ // didn't), so the agent second-guessed an approval it never saw. This is the
485
+ // one choke point every finalize flows through — assert the latest user
486
+ // message survived into canonical, and log loudly if not. Matched by content
487
+ // (robust across the display/canonical shapes) and skipped when compaction
488
+ // has legitimately summarized history. Log-only; never throws.
489
+ if (isMessageArray(conv._harnessMessages) && conv._harnessMessages.length > 0) {
490
+ const canonical = conv._harnessMessages;
491
+ const summarized = canonical.some((m) => m.metadata?.isCompactionSummary);
492
+ const lastUser = [...conv.messages].reverse().find((m) => m.role === "user");
493
+ const lastUserText = lastUser ? messageText(lastUser).trim() : "";
494
+ if (!summarized && lastUserText) {
495
+ const present = canonical.some((m) => m.role === "user" && messageText(m).trim() === lastUserText);
496
+ if (!present) {
497
+ console.error(
498
+ `[transcript-guard] conversation ${conv.conversationId}: model-facing transcript ` +
499
+ `is missing the latest user message — it diverged from the display transcript. ` +
500
+ `This is a resume/finalize message-assembly bug; the model will not see that turn's input.`,
501
+ );
502
+ }
503
+ }
504
+ }
505
+
376
506
  if (meta.toolResultArchive !== undefined) {
377
507
  conv._toolResultArchive = meta.toolResultArchive;
378
508
  }
package/src/state.ts CHANGED
@@ -17,10 +17,23 @@ export interface StateStore {
17
17
  delete(runId: string): Promise<void>;
18
18
  }
19
19
 
20
+ /**
21
+ * Whether a subagent actually accomplished its task — distinct from the *run*
22
+ * status (`status` below), which is "completed" whenever the run merely
23
+ * finished. A subagent that ran to completion but could not do the task (e.g.
24
+ * lacked the required tools) is `runStatus:"completed"` but `taskOutcome:
25
+ * "failed"`. Derived at subagent finalize; "unknown" when it can't be
26
+ * determined. Consumed by the compaction subagent ledger so a failed task can
27
+ * never be summarized as "completed".
28
+ */
29
+ export type SubagentTaskOutcome = "succeeded" | "failed" | "partial" | "unknown";
30
+
20
31
  export interface PendingSubagentResult {
21
32
  subagentId: string;
22
33
  task: string;
23
34
  status: "completed" | "error" | "stopped";
35
+ /** Did the subagent accomplish its task? See {@link SubagentTaskOutcome}. */
36
+ taskOutcome: SubagentTaskOutcome;
24
37
  result?: import("@poncho-ai/sdk").RunResult;
25
38
  error?: import("@poncho-ai/sdk").AgentFailure;
26
39
  timestamp: number;
@@ -5,8 +5,10 @@ import type { Message } from "@poncho-ai/sdk";
5
5
  import {
6
6
  compactMessages,
7
7
  findSafeSplitPoint,
8
+ findSafeSplitPointByTurns,
8
9
  resolveCompactionConfig,
9
10
  } from "../src/compaction.js";
11
+ import { deriveTaskOutcome, stripOutcomeVerdict } from "../src/orchestrator/orchestrator.js";
10
12
 
11
13
  // ── Fake model ──────────────────────────────────────────────────────────
12
14
  // A MockLanguageModelV3 whose doGenerate returns a fixed text and records the
@@ -254,9 +256,9 @@ describe("compactMessages", () => {
254
256
  expect(sentPrompt).toContain("MERGE AND UPDATE");
255
257
  });
256
258
 
257
- it("still truncates non-prior-summary long messages to 1200 chars", async () => {
259
+ it("still truncates non-prior-summary long messages to the per-message cap", async () => {
258
260
  const { model, prompts } = fakeModel("S");
259
- const longUser = "X".repeat(3000);
261
+ const longUser = "X".repeat(6000); // > SUMMARIZATION_MESSAGE_TRUNCATION_CHARS (4000)
260
262
  const messages: Message[] = [
261
263
  userMsg(longUser),
262
264
  assistantText("a0"),
@@ -272,3 +274,123 @@ describe("compactMessages", () => {
272
274
  expect(sentPrompt).not.toContain("MERGE AND UPDATE");
273
275
  });
274
276
  });
277
+
278
+ describe("findSafeSplitPointByTurns", () => {
279
+ const turns = (n: number, assistantText = "a"): Message[] => {
280
+ const msgs: Message[] = [];
281
+ for (let i = 0; i < n; i++) {
282
+ msgs.push(userMsg(`u${i}`));
283
+ msgs.push({ role: "assistant", content: `${assistantText}${i}` });
284
+ }
285
+ return msgs;
286
+ };
287
+
288
+ it("preserves the last N whole turns verbatim", () => {
289
+ const messages = turns(6); // userIdx = [0,2,4,6,8,10]
290
+ // keepRecentTurns=4, generous token budget → split before the 4th-from-last
291
+ // user message (index 4), preserving turns u2..u5.
292
+ const idx = findSafeSplitPointByTurns(messages, 4, 6, 1_000_000);
293
+ expect(idx).toBe(4);
294
+ const preserved = messages.slice(idx);
295
+ const userCount = preserved.filter((m) => m.role === "user").length;
296
+ expect(userCount).toBe(4);
297
+ });
298
+
299
+ it("reduces N when N turns exceed the preserved-token budget", () => {
300
+ // Each assistant turn is large (~460 tokens); 4 turns preserved would blow a
301
+ // small budget, so it must fall back to fewer turns.
302
+ const big = "Z".repeat(1600);
303
+ const messages = turns(6, big); // userIdx=[0,2,4,6,8,10]
304
+ const idx = findSafeSplitPointByTurns(messages, 4, 6, 1200);
305
+ // n=4 (~1840 tok) and n=3 (~1380 tok) exceed 1200; n=2 (~920 tok) fits →
306
+ // split at userIdx[len-2] = index 8, preserving 2 turns.
307
+ expect(idx).toBe(8);
308
+ expect(messages.slice(idx).filter((m) => m.role === "user").length).toBe(2);
309
+ });
310
+
311
+ it("returns -1 for a single giant turn with no earlier user boundary", () => {
312
+ // One user message followed by many tool rounds — no safe boundary to
313
+ // compact against. (The message-based fallback also finds none.)
314
+ const messages: Message[] = [userMsg("do a big thing")];
315
+ for (let i = 0; i < 8; i++) {
316
+ messages.push(assistantToolCall(`step ${i}`, "run_code"));
317
+ messages.push(toolResult(`out ${i}`));
318
+ }
319
+ expect(findSafeSplitPointByTurns(messages, 4, 6, 1_000_000)).toBe(-1);
320
+ });
321
+ });
322
+
323
+ describe("deriveTaskOutcome / stripOutcomeVerdict", () => {
324
+ it("parses the self-declared verdict", () => {
325
+ expect(deriveTaskOutcome("did it [[OUTCOME: succeeded]] all good", false)).toBe("succeeded");
326
+ expect(deriveTaskOutcome("partial [[OUTCOME: partial]] some left", false)).toBe("partial");
327
+ expect(deriveTaskOutcome("couldn't [[OUTCOME: failed]] no tools", false)).toBe("failed");
328
+ });
329
+
330
+ it("defaults to unknown when no verdict is present (never assumes success)", () => {
331
+ expect(deriveTaskOutcome("I finished everything.", false)).toBe("unknown");
332
+ });
333
+
334
+ it("treats abnormal ends and empty output as failed", () => {
335
+ expect(deriveTaskOutcome("whatever", true)).toBe("failed");
336
+ expect(deriveTaskOutcome(" ", false)).toBe("failed");
337
+ });
338
+
339
+ it("strips the verdict marker from delivered text", () => {
340
+ expect(stripOutcomeVerdict("Here is the report.\n[[OUTCOME: failed]] missing API key")).toBe(
341
+ "Here is the report.",
342
+ );
343
+ expect(stripOutcomeVerdict("No verdict here")).toBe("No verdict here");
344
+ });
345
+ });
346
+
347
+ describe("compactMessages — failure fidelity", () => {
348
+ const config = resolveCompactionConfig({ keepRecentTurns: 1 });
349
+
350
+ it("renders a failed subagent as failed (never 'completed') and keeps its reason", async () => {
351
+ const { model } = fakeModel("SUMMARY");
352
+ const messages: Message[] = [
353
+ userMsg("please read my chats"),
354
+ assistantText("delegating to a subagent"),
355
+ userMsg(
356
+ '[Subagent Result] Subagent "read chats" (sub_fail) failed:\n\nI could not access the LinkedIn tools, so I read nothing.',
357
+ {
358
+ _subagentCallback: true,
359
+ subagentId: "sub_fail",
360
+ task: "read chats",
361
+ taskOutcome: "failed",
362
+ } as Message["metadata"],
363
+ ),
364
+ assistantText("continuing"),
365
+ userMsg("how did it go?"),
366
+ assistantText("let me check"),
367
+ ];
368
+ const res = await compactMessages(model, messages, config);
369
+ const content = res.messages[0]!.content as string;
370
+ expect(content).toContain("## Subagents");
371
+ expect(content).toContain("sub_fail");
372
+ // Rendered with the failed outcome, not "completed".
373
+ expect(content).toContain("— failed");
374
+ expect(content).not.toContain("(sub_fail) — completed");
375
+ // The failure reason survives verbatim.
376
+ expect(content).toContain("could not access the LinkedIn tools");
377
+ });
378
+
379
+ it("keeps failure-bearing messages when the input budget forces drops", async () => {
380
+ const { model, prompts } = fakeModel("S");
381
+ // Many bulky non-failure messages plus one early failure. The failure must
382
+ // survive into the summarizer input even if older bulk is dropped.
383
+ const messages: Message[] = [userMsg("start")];
384
+ messages.push(toolResult("ERROR: the deploy failed because the token expired"));
385
+ for (let i = 0; i < 400; i++) {
386
+ messages.push(assistantText("filler ".repeat(200) + i));
387
+ messages.push(userMsg(`ok ${i}`));
388
+ }
389
+ messages.push(userMsg("final question"));
390
+ messages.push(assistantText("final answer"));
391
+ await compactMessages(model, messages, config);
392
+ const sentPrompt = prompts.join("\n");
393
+ // The failure line is marked important and never dropped.
394
+ expect(sentPrompt).toContain("the deploy failed because the token expired");
395
+ });
396
+ });
@@ -0,0 +1,156 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { Message } from "@poncho-ai/sdk";
3
+ import {
4
+ assembleCheckpointMessages,
5
+ buildToolResultMessage,
6
+ buildResumeCheckpoints,
7
+ applyTurnMetadata,
8
+ type StoredApproval,
9
+ } from "../src/orchestrator/index.js";
10
+ import type { Conversation } from "../src/state.js";
11
+
12
+ const conv = (overrides: Partial<Conversation> & Pick<Conversation, "messages">): Conversation => ({
13
+ conversationId: "conv_test",
14
+ title: "test",
15
+ ownerId: "owner",
16
+ tenantId: null,
17
+ createdAt: Date.now(),
18
+ updatedAt: Date.now(),
19
+ ...overrides,
20
+ });
21
+
22
+ // An assistant tool-call message is JSON `{ text, tool_calls: [...] }` in the
23
+ // canonical transcript.
24
+ const assistantToolCall = (text: string, calls: Array<{ id: string; name: string }>): Message => ({
25
+ role: "assistant",
26
+ content: JSON.stringify({ text, tool_calls: calls.map((c) => ({ ...c, input: {} })) }),
27
+ });
28
+
29
+ const meta = (harnessMessages?: Message[]) => ({
30
+ latestRunId: "",
31
+ contextTokens: 0,
32
+ contextWindow: 0,
33
+ harnessMessages,
34
+ });
35
+
36
+ describe("assembleCheckpointMessages", () => {
37
+ it("initial checkpoint: prior history (sliced by base) + delta", () => {
38
+ const prior: Message[] = [
39
+ { role: "user", content: "hi" },
40
+ { role: "assistant", content: "hello" },
41
+ ];
42
+ // Task-bearing turn: the delta carries the turn's user message + the
43
+ // assistant preamble/tool-call. base = prior length.
44
+ const delta: Message[] = [
45
+ { role: "user", content: "yeah those are a lot better" },
46
+ assistantToolCall("I found the two PE events…", [{ id: "call_1", name: "update_event" }]),
47
+ ];
48
+ const checkpoint = { baseMessageCount: prior.length, checkpointMessages: delta } as StoredApproval;
49
+ const out = assembleCheckpointMessages(conv({ messages: prior }), checkpoint);
50
+
51
+ expect(out).toHaveLength(4);
52
+ expect(out.some((m) => m.role === "user" && m.content === "yeah those are a lot better")).toBe(true);
53
+ expect(out.some((m) => typeof m.content === "string" && m.content.includes("I found the two PE events"))).toBe(true);
54
+ });
55
+
56
+ it("resume convention: base 0 + full canonical returns the full canonical verbatim", () => {
57
+ const full: Message[] = [
58
+ { role: "user", content: "hi" },
59
+ { role: "user", content: "approve this" },
60
+ assistantToolCall("preamble", [{ id: "call_1", name: "t" }]),
61
+ ];
62
+ const checkpoint = { baseMessageCount: 0, checkpointMessages: full } as StoredApproval;
63
+ // Display messages are irrelevant when base is 0 — the reconstruction must
64
+ // NOT depend on them (this is what the old PonchOS hand-merge got wrong).
65
+ const out = assembleCheckpointMessages(conv({ messages: [{ role: "user", content: "unrelated display" }] }), checkpoint);
66
+ expect(out).toEqual(full);
67
+ });
68
+ });
69
+
70
+ describe("buildToolResultMessage", () => {
71
+ const asst = assistantToolCall("", [{ id: "call_1", name: "update_event" }]);
72
+
73
+ it("pairs a provided result by callId", () => {
74
+ const msg = buildToolResultMessage(asst, [{ callId: "call_1", toolName: "update_event", result: { ok: true } }]);
75
+ expect(msg?.role).toBe("tool");
76
+ expect(msg?.content).toContain("call_1");
77
+ expect(msg?.content).toContain("ok");
78
+ });
79
+
80
+ it("emits the deferred-error marker when a result is missing", () => {
81
+ const msg = buildToolResultMessage(asst, []);
82
+ expect(msg?.content).toContain("Tool execution deferred");
83
+ });
84
+
85
+ it("returns undefined for a non-assistant / non-tool-call message", () => {
86
+ expect(buildToolResultMessage({ role: "user", content: "hi" }, [])).toBeUndefined();
87
+ expect(buildToolResultMessage({ role: "assistant", content: "plain text" }, [])).toBeUndefined();
88
+ });
89
+ });
90
+
91
+ describe("buildResumeCheckpoints + round-trip (the regression)", () => {
92
+ it("stores full canonical (base 0) so the NEXT resume reconstructs without dropping the user turn", () => {
93
+ // The continuation ran with this full canonical (incl. the turn's user
94
+ // message + preamble) and appended a tool result.
95
+ const fullWithResults: Message[] = [
96
+ { role: "user", content: "hi" },
97
+ { role: "assistant", content: "hello" },
98
+ { role: "user", content: "approve this" },
99
+ assistantToolCall("preamble text", [{ id: "call_1", name: "t1" }]),
100
+ { role: "tool", content: JSON.stringify([{ type: "tool_result", tool_use_id: "call_1", content: "done" }]) },
101
+ ];
102
+ // It then gated again on a new tool (the re-checkpoint event delta).
103
+ const delta2: Message[] = [assistantToolCall("", [{ id: "call_2", name: "t2" }])];
104
+ const stored = buildResumeCheckpoints({
105
+ priorMessages: fullWithResults,
106
+ checkpointEvent: {
107
+ approvals: [{ approvalId: "a2", tool: "t2", toolCallId: "call_2", input: {} }],
108
+ checkpointMessages: delta2,
109
+ pendingToolCalls: [{ id: "call_2", name: "t2", input: {} }],
110
+ },
111
+ runId: "run_2",
112
+ });
113
+
114
+ expect(stored[0]!.baseMessageCount).toBe(0);
115
+ expect(stored[0]!.checkpointMessages).toEqual([...fullWithResults, ...delta2]);
116
+
117
+ // Next resume reconstructs from the stored checkpoint — display is
118
+ // deliberately unrelated to prove no dependence on it.
119
+ const reconstructed = assembleCheckpointMessages(
120
+ conv({ messages: [{ role: "user", content: "unrelated" }], pendingApprovals: stored }),
121
+ stored[0]!,
122
+ );
123
+ // The user turn + preamble that PonchOS used to drop MUST survive.
124
+ expect(reconstructed.some((m) => m.role === "user" && m.content === "approve this")).toBe(true);
125
+ expect(reconstructed.some((m) => typeof m.content === "string" && m.content.includes("preamble text"))).toBe(true);
126
+ });
127
+ });
128
+
129
+ describe("applyTurnMetadata transcript-integrity guard", () => {
130
+ const latestUser: Message = { role: "user", content: "the latest question", metadata: { id: "u_latest" } };
131
+
132
+ it("logs when the latest user message is missing from the canonical transcript", () => {
133
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
134
+ const c = conv({ messages: [{ role: "assistant", content: "earlier" }, latestUser] });
135
+ // Canonical is missing "the latest question" — the exact divergence.
136
+ applyTurnMetadata(c, meta([{ role: "user", content: "hi" }, { role: "assistant", content: "x" }]));
137
+ expect(spy).toHaveBeenCalledWith(expect.stringContaining("[transcript-guard]"));
138
+ spy.mockRestore();
139
+ });
140
+
141
+ it("stays silent when the latest user message is present in canonical", () => {
142
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
143
+ const c = conv({ messages: [latestUser] });
144
+ applyTurnMetadata(c, meta([{ role: "user", content: "the latest question" }, { role: "assistant", content: "reply" }]));
145
+ expect(spy).not.toHaveBeenCalled();
146
+ spy.mockRestore();
147
+ });
148
+
149
+ it("skips the check when canonical was legitimately compacted", () => {
150
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
151
+ const c = conv({ messages: [latestUser] });
152
+ applyTurnMetadata(c, meta([{ role: "assistant", content: "summary", metadata: { isCompactionSummary: true } }]));
153
+ expect(spy).not.toHaveBeenCalled();
154
+ spy.mockRestore();
155
+ });
156
+ });