@poncho-ai/harness 0.53.0 → 0.57.0

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,4 +1,4 @@
1
- import { getTextContent, type AgentEvent, type Message } from "@poncho-ai/sdk";
1
+ import { createLogger, getTextContent, type AgentEvent, type Message } from "@poncho-ai/sdk";
2
2
  import type { Conversation, ConversationStore, PendingSubagentResult } from "../state.js";
3
3
  import type { AgentHarness } from "../harness.js";
4
4
  import type { TelemetryEmitter } from "../telemetry.js";
@@ -27,6 +27,17 @@ import {
27
27
  CALLBACK_LOCK_STALE_MS,
28
28
  STALE_SUBAGENT_THRESHOLD_MS,
29
29
  } from "./subagents.js";
30
+ import {
31
+ appendEntriesSafe,
32
+ assistantAmendmentEntry,
33
+ assistantMessageEntry,
34
+ callbackStartedEntry,
35
+ subagentResultEntry,
36
+ userMessageEntry,
37
+ verifyEntriesParity,
38
+ } from "./entries-dual-write.js";
39
+
40
+ const dualWriteLog = createLogger("orchestrator:entries");
30
41
 
31
42
  // ── Subagent result extraction ──
32
43
 
@@ -491,6 +502,11 @@ export class AgentOrchestrator {
491
502
  if (!checkpointedRun) {
492
503
  const conv = await this.conversationStore.get(conversationId);
493
504
  if (conv) {
505
+ // Track which dual-write branch the blob took: an in-place merge onto
506
+ // the previous assistant bubble (→ assistant_amendment) or a fresh
507
+ // bubble (→ assistant_message).
508
+ let amendmentText: string | undefined;
509
+ let pushedAssistant: Message | undefined;
494
510
  const hasAssistantContent =
495
511
  draft.assistantResponse.length > 0 || draft.toolTimeline.length > 0 || draft.sections.length > 0;
496
512
  if (hasAssistantContent) {
@@ -519,15 +535,14 @@ export class AgentOrchestrator {
519
535
  } as Message["metadata"],
520
536
  },
521
537
  ];
538
+ amendmentText = draft.assistantResponse;
522
539
  } else {
523
- conv.messages = [
524
- ...prevMessages,
525
- {
526
- role: "assistant" as const,
527
- content: draft.assistantResponse,
528
- metadata: buildAssistantMetadata(draft),
529
- },
530
- ];
540
+ pushedAssistant = {
541
+ role: "assistant" as const,
542
+ content: draft.assistantResponse,
543
+ metadata: buildAssistantMetadata(draft),
544
+ };
545
+ conv.messages = [...prevMessages, pushedAssistant];
531
546
  }
532
547
  }
533
548
  applyTurnMetadata(conv, {
@@ -537,6 +552,62 @@ export class AgentOrchestrator {
537
552
  harnessMessages: execution?.runHarnessMessages,
538
553
  }, { shouldRebuildCanonical: true });
539
554
  await this.conversationStore.update(conv);
555
+
556
+ // DUAL-WRITE (mirrors the resume merge/push above). In-place merge →
557
+ // assistant_amendment targeting the latest assistant_message entry
558
+ // (BEST-EFFORT: the blob carries no entry id, so we resolve the target
559
+ // by reading the log's last assistant_message). Fresh bubble →
560
+ // assistant_message. Then parity-check. Fire-and-forget.
561
+ if (amendmentText !== undefined || pushedAssistant) {
562
+ const finalConv = conv;
563
+ const amendText = amendmentText;
564
+ const pushed = pushedAssistant;
565
+ void (async () => {
566
+ try {
567
+ if (pushed) {
568
+ await appendEntriesSafe(
569
+ this.conversationStore,
570
+ finalConv,
571
+ [assistantMessageEntry(pushed, `resume-${conversationId}`, latestRunId)],
572
+ dualWriteLog,
573
+ );
574
+ } else if (amendText !== undefined) {
575
+ const existing = await this.conversationStore.readEntries(
576
+ conversationId,
577
+ { types: ["assistant_message"] },
578
+ );
579
+ const target = existing[existing.length - 1];
580
+ if (target) {
581
+ await appendEntriesSafe(
582
+ this.conversationStore,
583
+ finalConv,
584
+ [assistantAmendmentEntry(target.id, amendText)],
585
+ dualWriteLog,
586
+ );
587
+ } else {
588
+ dualWriteLog.warn(
589
+ `[entries-dual-write] resume amendment for ${conversationId}: no assistant_message entry to target; skipped`,
590
+ );
591
+ }
592
+ }
593
+ await verifyEntriesParity(
594
+ this.conversationStore,
595
+ conversationId,
596
+ {
597
+ harnessMessages: finalConv._harnessMessages,
598
+ displayMessages: finalConv.messages,
599
+ },
600
+ dualWriteLog,
601
+ );
602
+ } catch (err) {
603
+ dualWriteLog.error(
604
+ `[entries-dual-write] resume finalize append failed for ${conversationId}: ${
605
+ err instanceof Error ? err.message : String(err)
606
+ }`,
607
+ );
608
+ }
609
+ })();
610
+ }
540
611
  }
541
612
  } else {
542
613
  const conv = await this.conversationStore.get(conversationId);
@@ -1158,6 +1229,9 @@ export class AgentOrchestrator {
1158
1229
  const callbackCount = (conversation.subagentCallbackCount ?? 0) + 1;
1159
1230
  conversation.subagentCallbackCount = callbackCount;
1160
1231
 
1232
+ // Collect the injected callback messages so the dual-write can append them
1233
+ // as hidden user_message entries (mirroring the blob pushes below).
1234
+ const injectedCallbackMessages: Message[] = [];
1161
1235
  for (const pr of pendingResults) {
1162
1236
  // An empty response is recoverable, not a dead end: the subagent's work
1163
1237
  // lives in its transcript even when it produced no closing summary (e.g.
@@ -1172,11 +1246,13 @@ export class AgentOrchestrator {
1172
1246
  : pr.error
1173
1247
  ? `Error: ${pr.error.message}`
1174
1248
  : "(no result)";
1175
- conversation.messages.push({
1249
+ const injected: Message = {
1176
1250
  role: "user",
1177
1251
  content: `[Subagent Result] Subagent "${pr.task}" (${pr.subagentId}) ${pr.status}:\n\n${resultBody}`,
1178
1252
  metadata: { _subagentCallback: true, subagentId: pr.subagentId, task: pr.task, timestamp: pr.timestamp } as Message["metadata"],
1179
- });
1253
+ };
1254
+ injectedCallbackMessages.push(injected);
1255
+ conversation.messages.push(injected);
1180
1256
  }
1181
1257
  const processedIds = new Set(pendingResults.map(pr => pr.subagentId));
1182
1258
  const freshForPending = await this.conversationStore.get(conversationId);
@@ -1187,6 +1263,48 @@ export class AgentOrchestrator {
1187
1263
  conversation.updatedAt = Date.now();
1188
1264
  await this.conversationStore.update(conversation);
1189
1265
 
1266
+ // DUAL-WRITE (mirrors the consume-pending + message-push blob writes
1267
+ // above): append a callback_started entry listing the consumed
1268
+ // subagent_result seqs (resolved by matching subagentId against the entry
1269
+ // log — BEST-EFFORT, since the blob's pending array carries no seq), plus
1270
+ // a hidden user_message entry per injected callback message.
1271
+ if (pendingResults.length > 0) {
1272
+ const turnId = `callback-${callbackCount}-${conversation.conversationId}`;
1273
+ void (async () => {
1274
+ try {
1275
+ const resultEntries = await this.conversationStore.readEntries(
1276
+ conversation.conversationId,
1277
+ { types: ["subagent_result"] },
1278
+ );
1279
+ const consumedIds = new Set(pendingResults.map((pr) => pr.subagentId));
1280
+ const consumedSeqs = resultEntries
1281
+ .filter(
1282
+ (e) =>
1283
+ e.type === "subagent_result" &&
1284
+ consumedIds.has(e.result.subagentId),
1285
+ )
1286
+ .map((e) => e.seq);
1287
+ await appendEntriesSafe(
1288
+ this.conversationStore,
1289
+ conversation,
1290
+ [
1291
+ callbackStartedEntry(consumedSeqs),
1292
+ ...injectedCallbackMessages.map((m) =>
1293
+ userMessageEntry(m, turnId, { hidden: true }),
1294
+ ),
1295
+ ],
1296
+ dualWriteLog,
1297
+ );
1298
+ } catch (err) {
1299
+ dualWriteLog.error(
1300
+ `[entries-dual-write] callback_started append failed for ${conversation.conversationId}: ${
1301
+ err instanceof Error ? err.message : String(err)
1302
+ }`,
1303
+ );
1304
+ }
1305
+ })();
1306
+ }
1307
+
1190
1308
  if (callbackCount > MAX_SUBAGENT_CALLBACK_COUNT) {
1191
1309
  console.warn(`[poncho][subagent-callback] Circuit breaker: ${callbackCount} callbacks for ${conversationId}, skipping re-run`);
1192
1310
  conversation.runningCallbackSince = undefined;
@@ -1256,12 +1374,14 @@ export class AgentOrchestrator {
1256
1374
  if (callbackNeedsContinuation || execution.draft.assistantResponse.length > 0 || execution.draft.toolTimeline.length > 0) {
1257
1375
  const freshConv = await this.conversationStore.get(conversationId);
1258
1376
  if (freshConv) {
1377
+ let callbackAssistantMsg: Message | undefined;
1259
1378
  if (!callbackNeedsContinuation) {
1260
- freshConv.messages.push({
1379
+ callbackAssistantMsg = {
1261
1380
  role: "assistant",
1262
1381
  content: execution.draft.assistantResponse,
1263
1382
  metadata: buildAssistantMetadata(execution.draft),
1264
- });
1383
+ };
1384
+ freshConv.messages.push(callbackAssistantMsg);
1265
1385
  }
1266
1386
  applyTurnMetadata(freshConv, {
1267
1387
  latestRunId: execution.latestRunId,
@@ -1275,6 +1395,30 @@ export class AgentOrchestrator {
1275
1395
  freshConv.runningCallbackSince = undefined;
1276
1396
  await this.conversationStore.update(freshConv);
1277
1397
 
1398
+ // DUAL-WRITE (mirrors the assistant push above): the callback re-run's
1399
+ // final assistant bubble. Only when not continuing (a continuation has
1400
+ // no final bubble yet). Then run the parity check on the rebuilt
1401
+ // transcript. Fire-and-forget; never blocks.
1402
+ if (callbackAssistantMsg) {
1403
+ const finalMsg = callbackAssistantMsg;
1404
+ void appendEntriesSafe(
1405
+ this.conversationStore,
1406
+ freshConv,
1407
+ [assistantMessageEntry(finalMsg, `callback-${conversationId}`, execution.latestRunId)],
1408
+ dualWriteLog,
1409
+ ).then(() =>
1410
+ verifyEntriesParity(
1411
+ this.conversationStore,
1412
+ conversationId,
1413
+ {
1414
+ harnessMessages: freshConv._harnessMessages,
1415
+ displayMessages: freshConv.messages,
1416
+ },
1417
+ dualWriteLog,
1418
+ ),
1419
+ );
1420
+ }
1421
+
1278
1422
  // Proactive messaging notification
1279
1423
  if (freshConv.channelMeta && execution.draft.assistantResponse.length > 0) {
1280
1424
  this.hooks?.onMessagingNotify?.(conversationId, execution.draft.assistantResponse);
@@ -1732,6 +1876,28 @@ export class AgentOrchestrator {
1732
1876
  parentConversationId: string,
1733
1877
  result: PendingSubagentResult,
1734
1878
  ): Promise<boolean> {
1879
+ // DUAL-WRITE (mirrors the appendSubagentResult blob write below): append a
1880
+ // subagent_result entry. Fire-and-forget; needs the parent's owner/tenant,
1881
+ // fetched cheaply. Never blocks or fails the reliable append.
1882
+ void (async () => {
1883
+ try {
1884
+ const parent = await this.conversationStore.get(parentConversationId);
1885
+ if (!parent) return;
1886
+ await appendEntriesSafe(
1887
+ this.conversationStore,
1888
+ parent,
1889
+ [subagentResultEntry(result)],
1890
+ dualWriteLog,
1891
+ );
1892
+ } catch (err) {
1893
+ dualWriteLog.error(
1894
+ `[entries-dual-write] subagent_result append failed for ${parentConversationId}: ${
1895
+ err instanceof Error ? err.message : String(err)
1896
+ }`,
1897
+ );
1898
+ }
1899
+ })();
1900
+
1735
1901
  try {
1736
1902
  await this.conversationStore.appendSubagentResult(parentConversationId, result);
1737
1903
  return true;
@@ -36,6 +36,15 @@ import {
36
36
  executeConversationTurn,
37
37
  flushTurnDraft,
38
38
  } from "./turn.js";
39
+ import {
40
+ appendEntriesSafe,
41
+ assistantMessageEntry,
42
+ compactionEntry,
43
+ harnessMessageEntries,
44
+ newHarnessMessagesThisTurn,
45
+ userMessageEntry,
46
+ verifyEntriesParity,
47
+ } from "./entries-dual-write.js";
39
48
 
40
49
  const log = createLogger("orchestrator");
41
50
 
@@ -185,6 +194,14 @@ export const runConversationTurn = async (
185
194
  await opts.conversationStore.update(conversation);
186
195
  };
187
196
 
197
+ // Snapshot the harness-message array as it stood BEFORE this turn so the
198
+ // finalize path can diff out the messages this turn appended (dual-write).
199
+ const preTurnHarnessMessages = conversation._harnessMessages
200
+ ? [...conversation._harnessMessages]
201
+ : undefined;
202
+ // The stable per-turn id used to group dual-write entries.
203
+ const turnId = assistantId;
204
+
188
205
  // Persist the user turn immediately so a crash mid-run still records what
189
206
  // the user said. Fire-and-forget — don't block the run.
190
207
  conversation.messages = [...historyMessages, userMessage];
@@ -197,6 +214,15 @@ export const runConversationTurn = async (
197
214
  );
198
215
  });
199
216
 
217
+ // DUAL-WRITE (additive, mirrors the user-turn blob write above): append a
218
+ // user_message entry. Fire-and-forget — never blocks or breaks the turn.
219
+ void appendEntriesSafe(
220
+ opts.conversationStore,
221
+ conversation,
222
+ [userMessageEntry(userMessage, turnId)],
223
+ log,
224
+ );
225
+
200
226
  try {
201
227
  const execution = await executeConversationTurn({
202
228
  harness: opts.harness,
@@ -247,6 +273,48 @@ export const runConversationTurn = async (
247
273
  ...existingHistory,
248
274
  ...preRunMessages.slice(0, removedCount),
249
275
  ];
276
+
277
+ // DUAL-WRITE (mirrors the compactedHistory blob write above): the
278
+ // compacted array is [summaryMessage, ...keptMessages]. BEST-EFFORT
279
+ // firstKeptSeq: the entry-log seqs of the kept harness messages
280
+ // aren't known here, so we derive a sentinel from the kept-count by
281
+ // reading the current max harness_message seq and pointing at the
282
+ // tail. We read the existing entries to compute it.
283
+ const summaryMessage = event.compactedMessages[0];
284
+ const keptCount = Math.max(0, event.compactedMessages.length - 1);
285
+ if (summaryMessage) {
286
+ void (async () => {
287
+ try {
288
+ const existing = await opts.conversationStore.readEntries(
289
+ opts.conversationId,
290
+ { types: ["harness_message"] },
291
+ );
292
+ // firstKeptSeq = seq of the (keptCount)-th-from-last existing
293
+ // harness message, so rebuild keeps exactly that many.
294
+ const harnessSeqs = existing.map((e) => e.seq);
295
+ const firstKeptSeq =
296
+ harnessSeqs.length >= keptCount && keptCount > 0
297
+ ? harnessSeqs[harnessSeqs.length - keptCount]!
298
+ : (harnessSeqs[harnessSeqs.length - 1] ?? 0) + 1;
299
+ await appendEntriesSafe(
300
+ opts.conversationStore,
301
+ conversation,
302
+ [
303
+ compactionEntry(summaryMessage, firstKeptSeq, {
304
+ tokensBefore: conversation.contextTokens,
305
+ }),
306
+ ],
307
+ log,
308
+ );
309
+ } catch (err) {
310
+ log.error(
311
+ `[entries-dual-write] compaction append failed: ${
312
+ err instanceof Error ? err.message : String(err)
313
+ }`,
314
+ );
315
+ }
316
+ })();
317
+ }
250
318
  }
251
319
  }
252
320
  if (event.type === "step:completed") {
@@ -387,6 +455,46 @@ export const runConversationTurn = async (
387
455
  { shouldRebuildCanonical },
388
456
  );
389
457
  await opts.conversationStore.update(conversation);
458
+
459
+ // DUAL-WRITE at finalize (mirrors applyTurnMetadata's _harnessMessages
460
+ // write + the final assistant bubble in conversation.messages):
461
+ // 1. harness_message entries for the messages this turn appended,
462
+ // 2. the final assistant_message entry.
463
+ // Best-effort + fire-and-forget; never blocks the return.
464
+ const finalAssistant =
465
+ conversation.messages[conversation.messages.length - 1];
466
+ const { messages: newHarness, approximate } = newHarnessMessagesThisTurn(
467
+ preTurnHarnessMessages,
468
+ conversation._harnessMessages,
469
+ );
470
+ if (approximate) {
471
+ log.warn(
472
+ `[entries-dual-write] ${opts.conversationId} harness-message diff approximate ` +
473
+ `(blob array shrank this turn — likely compaction); appended full context`,
474
+ );
475
+ }
476
+ const finalizeEntries = [
477
+ ...harnessMessageEntries(newHarness, turnId),
478
+ ...(finalAssistant && finalAssistant.role === "assistant"
479
+ ? [assistantMessageEntry(finalAssistant, turnId, latestRunId)]
480
+ : []),
481
+ ];
482
+ void appendEntriesSafe(
483
+ opts.conversationStore,
484
+ conversation,
485
+ finalizeEntries,
486
+ log,
487
+ ).then(() =>
488
+ verifyEntriesParity(
489
+ opts.conversationStore,
490
+ opts.conversationId,
491
+ {
492
+ harnessMessages: conversation._harnessMessages,
493
+ displayMessages: conversation.messages,
494
+ },
495
+ log,
496
+ ),
497
+ );
390
498
  }
391
499
 
392
500
  return {
package/src/state.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Message } from "@poncho-ai/sdk";
2
+ import type { ConversationEntry, NewConversationEntry } from "./storage/entries.js";
2
3
 
3
4
  export interface ConversationState {
4
5
  runId: string;
@@ -142,6 +143,23 @@ export interface ConversationStore {
142
143
  clearCallbackLock(conversationId: string): Promise<Conversation | undefined>;
143
144
  /** List thread conversations anchored under `parentConversationId`. */
144
145
  listThreads(parentConversationId: string): Promise<ConversationSummary[]>;
146
+ /**
147
+ * Append entries to a conversation's append-only log (Phase 3 substrate).
148
+ * Assigns a per-conversation monotonic `seq` and a `createdAt` to each
149
+ * entry, persists them in order, and returns the stored entries with those
150
+ * fields filled in. Additive — no existing read path consumes these yet.
151
+ */
152
+ appendEntries(
153
+ conversationId: string,
154
+ agentId: string,
155
+ tenantId: string | null,
156
+ entries: NewConversationEntry[],
157
+ ): Promise<ConversationEntry[]>;
158
+ /** Read a conversation's entries ordered by `seq` ascending. */
159
+ readEntries(
160
+ conversationId: string,
161
+ opts?: { types?: string[]; afterSeq?: number; limit?: number },
162
+ ): Promise<ConversationEntry[]>;
145
163
  }
146
164
 
147
165
  export type StateProviderName =
@@ -201,6 +219,7 @@ export class InMemoryStateStore implements StateStore {
201
219
 
202
220
  export class InMemoryConversationStore implements ConversationStore {
203
221
  private readonly conversations = new Map<string, Conversation>();
222
+ private readonly entries = new Map<string, ConversationEntry[]>();
204
223
  private readonly ttlMs?: number;
205
224
 
206
225
  constructor(ttlSeconds?: number) {
@@ -372,6 +391,43 @@ export class InMemoryConversationStore implements ConversationStore {
372
391
  channelMeta: c.channelMeta,
373
392
  }));
374
393
  }
394
+
395
+ async appendEntries(
396
+ conversationId: string,
397
+ _agentId: string,
398
+ _tenantId: string | null,
399
+ entries: NewConversationEntry[],
400
+ ): Promise<ConversationEntry[]> {
401
+ const list = this.entries.get(conversationId) ?? [];
402
+ // seq is per-conversation: max existing seq + 1, then consecutive.
403
+ let nextSeq = list.reduce((max, e) => (e.seq > max ? e.seq : max), 0) + 1;
404
+ const now = Date.now();
405
+ const stored: ConversationEntry[] = entries.map(
406
+ (e) => ({ ...e, seq: nextSeq++, createdAt: now }) as ConversationEntry,
407
+ );
408
+ this.entries.set(conversationId, [...list, ...stored]);
409
+ return stored;
410
+ }
411
+
412
+ async readEntries(
413
+ conversationId: string,
414
+ opts?: { types?: string[]; afterSeq?: number; limit?: number },
415
+ ): Promise<ConversationEntry[]> {
416
+ let list = (this.entries.get(conversationId) ?? [])
417
+ .slice()
418
+ .sort((a, b) => a.seq - b.seq);
419
+ if (opts?.types && opts.types.length > 0) {
420
+ const allowed = new Set(opts.types);
421
+ list = list.filter((e) => allowed.has(e.type));
422
+ }
423
+ if (typeof opts?.afterSeq === "number") {
424
+ list = list.filter((e) => e.seq > opts.afterSeq!);
425
+ }
426
+ if (typeof opts?.limit === "number") {
427
+ list = list.slice(0, opts.limit);
428
+ }
429
+ return list;
430
+ }
375
431
  }
376
432
 
377
433
  export type ConversationSummary = {
@@ -5,6 +5,7 @@ import type {
5
5
  ConversationSummary,
6
6
  PendingSubagentResult,
7
7
  } from "../state.js";
8
+ import type { ConversationEntry, NewConversationEntry } from "./entries.js";
8
9
  import type { MainMemory } from "../memory.js";
9
10
  import type { TodoItem } from "../todo-tools.js";
10
11
  import type { Reminder, ReminderCreateInput, ReminderStatus } from "../reminder-store.js";
@@ -77,6 +78,23 @@ export interface StorageEngine {
77
78
  clearCallbackLock(conversationId: string): Promise<Conversation | undefined>;
78
79
  /** List thread conversations anchored under `parentConversationId`. */
79
80
  listThreads(parentConversationId: string): Promise<ConversationSummary[]>;
81
+ /**
82
+ * Append entries to a conversation's append-only log (Phase 3 substrate).
83
+ * Assigns a per-conversation monotonic `seq` and `createdAt` to each entry,
84
+ * persists them in order, and returns the stored entries. Additive — no
85
+ * read path consumes these yet.
86
+ */
87
+ appendEntries(
88
+ conversationId: string,
89
+ agentId: string,
90
+ tenantId: string | null,
91
+ entries: NewConversationEntry[],
92
+ ): Promise<ConversationEntry[]>;
93
+ /** Read a conversation's entries ordered by `seq` ascending. */
94
+ readEntries(
95
+ conversationId: string,
96
+ opts?: { types?: string[]; afterSeq?: number; limit?: number },
97
+ ): Promise<ConversationEntry[]>;
80
98
  };
81
99
 
82
100
  // --- Memory (replaces MemoryStore) ---