@inflexa-ai/harness 0.45.0 → 0.45.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.
Files changed (122) hide show
  1. package/dist/agents/conversation-agent.d.ts +10 -0
  2. package/dist/agents/conversation-agent.js +19 -1
  3. package/dist/agents/sandbox/shared.d.ts +23 -2
  4. package/dist/agents/sandbox/shared.js +51 -18
  5. package/dist/agents/sandbox/translational-safety-agent.js +26 -0
  6. package/dist/agents/sandbox/types.d.ts +24 -1
  7. package/dist/agents/sandbox-catalog.d.ts +14 -0
  8. package/dist/agents/sandbox-catalog.js +19 -0
  9. package/dist/app/approve-plan.d.ts +5 -2
  10. package/dist/app/approve-plan.js +1 -1
  11. package/dist/app/chat-turn.js +6 -3
  12. package/dist/config/environment-stores.d.ts +4 -2
  13. package/dist/contracts/chat-events.d.ts +12 -2
  14. package/dist/contracts/chat-events.js +1 -1
  15. package/dist/contracts/chat-frame.js +6 -2
  16. package/dist/contracts/index.d.ts +1 -1
  17. package/dist/contracts/schemas/chat-events.d.ts +25 -0
  18. package/dist/contracts/schemas/chat-events.js +7 -0
  19. package/dist/contracts/schemas/chat-parts.d.ts +39 -0
  20. package/dist/contracts/schemas/chat-parts.js +17 -0
  21. package/dist/contracts/schemas/index.d.ts +1 -1
  22. package/dist/contracts/schemas/index.js +1 -1
  23. package/dist/execution/dbos-workflow-purger.d.ts +2 -0
  24. package/dist/execution/dbos-workflow-purger.js +21 -14
  25. package/dist/execution/run-canceler.d.ts +33 -11
  26. package/dist/execution/run-canceler.js +129 -27
  27. package/dist/execution/run-end-record.d.ts +30 -0
  28. package/dist/execution/run-end-record.js +27 -0
  29. package/dist/index.d.ts +9 -5
  30. package/dist/index.js +11 -1
  31. package/dist/loop/call-guard.d.ts +50 -0
  32. package/dist/loop/call-guard.js +90 -0
  33. package/dist/loop/compaction.d.ts +5 -0
  34. package/dist/loop/compaction.js +17 -0
  35. package/dist/loop/run-agent.d.ts +9 -0
  36. package/dist/loop/run-agent.js +15 -1
  37. package/dist/loop/run-to-terminal.js +4 -1
  38. package/dist/loop/types.d.ts +6 -0
  39. package/dist/memory/conversation-display-recorder.d.ts +0 -2
  40. package/dist/memory/conversation-display-recorder.js +28 -3
  41. package/dist/memory/thread-history.d.ts +34 -7
  42. package/dist/memory/thread-history.js +151 -42
  43. package/dist/prompts/briefing.d.ts +57 -3
  44. package/dist/prompts/briefing.js +129 -2
  45. package/dist/prompts/planner.js +13 -2
  46. package/dist/provenance/seam.d.ts +23 -15
  47. package/dist/provenance/seam.js +7 -5
  48. package/dist/providers/ai-sdk.js +87 -7
  49. package/dist/providers/types.d.ts +6 -0
  50. package/dist/reference-data/catalog.js +141 -2
  51. package/dist/report-model/authoring-grammar.d.ts +26 -26
  52. package/dist/sandbox/image-packages.d.ts +26 -4
  53. package/dist/sandbox/image-packages.js +18 -4
  54. package/dist/schemas/data-profile-schemas.d.ts +12 -12
  55. package/dist/schemas/plan-schemas.d.ts +57 -3
  56. package/dist/schemas/plan-schemas.js +1 -1
  57. package/dist/schemas/workflow-state.d.ts +113 -0
  58. package/dist/schemas/workflow-state.js +54 -0
  59. package/dist/state/artifacts.d.ts +17 -1
  60. package/dist/state/artifacts.js +21 -3
  61. package/dist/state/index.d.ts +2 -2
  62. package/dist/state/index.js +1 -1
  63. package/dist/state/runs.d.ts +43 -9
  64. package/dist/state/runs.js +85 -14
  65. package/dist/state/step-executions.d.ts +9 -0
  66. package/dist/state/step-executions.js +22 -0
  67. package/dist/tools/ad-hoc-router.d.ts +2 -0
  68. package/dist/tools/ad-hoc-router.js +18 -2
  69. package/dist/tools/capability-data.d.ts +36 -0
  70. package/dist/tools/capability-data.js +73 -0
  71. package/dist/tools/execute-analysis.d.ts +37 -9
  72. package/dist/tools/execute-analysis.js +131 -21
  73. package/dist/tools/knowledge/check.d.ts +60 -0
  74. package/dist/tools/knowledge/check.js +92 -0
  75. package/dist/tools/knowledge/client.d.ts +382 -0
  76. package/dist/tools/knowledge/client.js +302 -0
  77. package/dist/tools/knowledge/environment.d.ts +78 -0
  78. package/dist/tools/knowledge/environment.js +105 -0
  79. package/dist/tools/knowledge/index.d.ts +49 -0
  80. package/dist/tools/knowledge/index.js +70 -0
  81. package/dist/tools/knowledge/local-slots.d.ts +62 -0
  82. package/dist/tools/knowledge/local-slots.js +177 -0
  83. package/dist/tools/knowledge/pin.d.ts +14 -0
  84. package/dist/tools/knowledge/pin.js +17 -0
  85. package/dist/tools/knowledge/recommend.d.ts +92 -0
  86. package/dist/tools/knowledge/recommend.js +98 -0
  87. package/dist/tools/knowledge/situation.d.ts +143 -0
  88. package/dist/tools/knowledge/situation.js +164 -0
  89. package/dist/tools/knowledge/skeleton.d.ts +69 -0
  90. package/dist/tools/knowledge/skeleton.js +202 -0
  91. package/dist/tools/knowledge/template.d.ts +137 -0
  92. package/dist/tools/knowledge/template.js +267 -0
  93. package/dist/tools/research/generate-plan.d.ts +23 -0
  94. package/dist/tools/research/generate-plan.js +203 -12
  95. package/dist/tools/research/guarded-address.d.ts +20 -0
  96. package/dist/tools/research/guarded-address.js +148 -0
  97. package/dist/tools/research/searxng-engine.d.ts +40 -0
  98. package/dist/tools/research/searxng-engine.js +120 -0
  99. package/dist/tools/research/web-fetch.d.ts +124 -0
  100. package/dist/tools/research/web-fetch.js +627 -0
  101. package/dist/tools/research/web-search.d.ts +75 -0
  102. package/dist/tools/research/web-search.js +112 -0
  103. package/dist/tools/sandbox/list-available-packages.d.ts +7 -4
  104. package/dist/tools/sandbox/list-available-packages.js +12 -4
  105. package/dist/tools/sandbox/list-available-refs.d.ts +21 -0
  106. package/dist/tools/sandbox/list-available-refs.js +60 -3
  107. package/dist/tools/sandbox/report-blocker.js +5 -1
  108. package/dist/tools/terminal-text.d.ts +11 -0
  109. package/dist/tools/terminal-text.js +19 -0
  110. package/dist/tools/workspace/decision-record.d.ts +81 -0
  111. package/dist/tools/workspace/decision-record.js +117 -0
  112. package/dist/tools/workspace/edit-file.d.ts +3 -1
  113. package/dist/tools/workspace/edit-file.js +20 -2
  114. package/dist/tools/workspace/execute-command.d.ts +17 -0
  115. package/dist/tools/workspace/execute-command.js +28 -1
  116. package/dist/tools/workspace/index.d.ts +1 -0
  117. package/dist/tools/workspace/index.js +1 -0
  118. package/dist/tools/workspace/mutator.d.ts +1 -1
  119. package/dist/workflows/execute-analysis.d.ts +58 -18
  120. package/dist/workflows/execute-analysis.js +273 -58
  121. package/dist/workflows/sandbox-step.d.ts +12 -0
  122. package/package.json +5 -1
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The call guard: a bound on repeated tool calls inside one agent run.
3
+ *
4
+ * A model that treats a tool answer as a state to poll calls the same tool
5
+ * with the same input again and again, or drifts through one query after
6
+ * another, until the iteration cap or the wall clock ends the run. A frontier
7
+ * model reads an "unavailable" answer once and continues; a smaller model
8
+ * does not. The guard makes that difference a host decision: the third call
9
+ * with an input the run already sent, the call past the budget of one tool,
10
+ * or the call past the budget of the whole run, answers a tool error that
11
+ * tells the model to continue with what it has. The service behind the tool never sees the refused call.
12
+ *
13
+ * The guard wraps a list of tools for ONE run. Build it where the tools of
14
+ * the run are built, never at construction of the host, thus the counters
15
+ * start at zero for every run. A terminal tool (one that ends the run) must
16
+ * not be wrapped: a refusal there would strand the run.
17
+ */
18
+ import type { Tool } from "../tools/define-tool.js";
19
+ export interface CallGuardPolicy {
20
+ /** The calls with one identical input that a run may make to one tool. The next one is refused. */
21
+ readonly identicalLimit: number;
22
+ /** The calls, with any input, that a run may make to one tool. The next one is refused. */
23
+ readonly perToolLimit: number;
24
+ /** The calls, with any input, that a run may make across every guarded tool. The next one is refused by every tool. */
25
+ readonly totalLimit: number;
26
+ }
27
+ /**
28
+ * Two identical calls let a model confirm an answer once. Twelve calls of one
29
+ * tool is above the largest count a frontier planner made in the Phase 0
30
+ * campaign (six and a half calls per plan across ALL tools) and far below the
31
+ * two hundred that a looping planner reached. Forty calls across every tool
32
+ * bounds the round over the tools that stays under each per-tool budget: in
33
+ * the 32-task campaign every submitted Sonnet plan but two needed fewer, and
34
+ * the one plan that ran to the wall clock made 62.
35
+ */
36
+ export declare const DEFAULT_CALL_GUARD: CallGuardPolicy;
37
+ /** A stable key for one input: the JSON text with the object keys sorted at every depth. */
38
+ export declare function canonicalInputKey(input: unknown): string;
39
+ export interface GuardedCallRefusal {
40
+ readonly tool: string;
41
+ readonly kind: "identical" | "budget" | "total";
42
+ readonly calls: number;
43
+ }
44
+ export interface CallGuardOptions {
45
+ readonly policy?: CallGuardPolicy;
46
+ /** Called on each refusal, for the log of the host. */
47
+ readonly onRefusal?: (refusal: GuardedCallRefusal) => void;
48
+ }
49
+ /** Wrap each tool so its `execute` obeys the policy for the life of the returned list. */
50
+ export declare function guardRepeatedCalls(tools: readonly Tool[], options?: CallGuardOptions): Tool[];
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The call guard: a bound on repeated tool calls inside one agent run.
3
+ *
4
+ * A model that treats a tool answer as a state to poll calls the same tool
5
+ * with the same input again and again, or drifts through one query after
6
+ * another, until the iteration cap or the wall clock ends the run. A frontier
7
+ * model reads an "unavailable" answer once and continues; a smaller model
8
+ * does not. The guard makes that difference a host decision: the third call
9
+ * with an input the run already sent, the call past the budget of one tool,
10
+ * or the call past the budget of the whole run, answers a tool error that
11
+ * tells the model to continue with what it has. The service behind the tool never sees the refused call.
12
+ *
13
+ * The guard wraps a list of tools for ONE run. Build it where the tools of
14
+ * the run are built, never at construction of the host, thus the counters
15
+ * start at zero for every run. A terminal tool (one that ends the run) must
16
+ * not be wrapped: a refusal there would strand the run.
17
+ */
18
+ import { err } from "neverthrow";
19
+ /**
20
+ * Two identical calls let a model confirm an answer once. Twelve calls of one
21
+ * tool is above the largest count a frontier planner made in the Phase 0
22
+ * campaign (six and a half calls per plan across ALL tools) and far below the
23
+ * two hundred that a looping planner reached. Forty calls across every tool
24
+ * bounds the round over the tools that stays under each per-tool budget: in
25
+ * the 32-task campaign every submitted Sonnet plan but two needed fewer, and
26
+ * the one plan that ran to the wall clock made 62.
27
+ */
28
+ export const DEFAULT_CALL_GUARD = { identicalLimit: 2, perToolLimit: 12, totalLimit: 40 };
29
+ /** A stable key for one input: the JSON text with the object keys sorted at every depth. */
30
+ export function canonicalInputKey(input) {
31
+ return JSON.stringify(sortKeys(input));
32
+ }
33
+ function sortKeys(value) {
34
+ if (Array.isArray(value))
35
+ return value.map(sortKeys);
36
+ if (value !== null && typeof value === "object") {
37
+ const out = {};
38
+ for (const key of Object.keys(value).sort())
39
+ out[key] = sortKeys(value[key]);
40
+ return out;
41
+ }
42
+ return value;
43
+ }
44
+ /** Wrap each tool so its `execute` obeys the policy for the life of the returned list. */
45
+ export function guardRepeatedCalls(tools, options = {}) {
46
+ const policy = options.policy ?? DEFAULT_CALL_GUARD;
47
+ let total = 0;
48
+ return tools.map((tool) => {
49
+ let calls = 0;
50
+ const byInput = new Map();
51
+ const guarded = {
52
+ ...tool,
53
+ ...(tool.describeCall ? { describeCall: tool.describeCall.bind(tool) } : {}),
54
+ ...(tool.describeResult ? { describeResult: tool.describeResult.bind(tool) } : {}),
55
+ async execute(input, ctx) {
56
+ calls += 1;
57
+ const key = canonicalInputKey(input);
58
+ const identical = (byInput.get(key) ?? 0) + 1;
59
+ byInput.set(key, identical);
60
+ if (identical > policy.identicalLimit) {
61
+ options.onRefusal?.({ tool: tool.id, kind: "identical", calls: identical });
62
+ return err({
63
+ error: `This run already called ${tool.id} with this exact input ${identical - 1} times, and the answer does not change within a run. ` +
64
+ "Use the answer you have and continue. If the answer was that a resource is absent, plan without it or ask for it; do not search again.",
65
+ retryable: false,
66
+ });
67
+ }
68
+ total += 1;
69
+ if (total > policy.totalLimit) {
70
+ options.onRefusal?.({ tool: tool.id, kind: "total", calls: total });
71
+ return err({
72
+ error: `This run made ${policy.totalLimit} calls across its search tools, which is the search budget of one run. ` +
73
+ "Submit the plan you have now. If a required resource is absent, ask for it; do not search again.",
74
+ retryable: false,
75
+ });
76
+ }
77
+ if (calls > policy.perToolLimit) {
78
+ options.onRefusal?.({ tool: tool.id, kind: "budget", calls });
79
+ return err({
80
+ error: `This run made ${policy.perToolLimit} calls of ${tool.id}, which is the budget of one run for one tool. ` +
81
+ "Continue with what you have. If a required resource is absent, ask for it; do not search again.",
82
+ retryable: false,
83
+ });
84
+ }
85
+ return tool.execute(input, ctx);
86
+ },
87
+ };
88
+ return guarded;
89
+ });
90
+ }
@@ -10,6 +10,11 @@ export interface CompactionRules {
10
10
  /** The budget before the user message of a turn joins the conversation. Absent: no compaction before the user message. */
11
11
  readonly turnStartBudget?: number;
12
12
  }
13
+ /**
14
+ * The rules of an agent for a model with `contextWindow`. The budget is the smaller of the declared budget and 80% of
15
+ * the window. The turn-start budget keeps its ratio to the budget. An absent window keeps the declared rules.
16
+ */
17
+ export declare function rulesForContextWindow(rules: CompactionRules, contextWindow: number | undefined): CompactionRules;
13
18
  /** How the loop compacts its conversation when the last request passed a budget. */
14
19
  export interface CompactionPolicy extends CompactionRules {
15
20
  /** The provider of the conversation, with no text stream to the surface: the summary is no reply. */
@@ -1,2 +1,19 @@
1
1
  /** The cap of requests of one compaction exchange: up to 3 rounds of memory edits, and then the summary. */
2
2
  export const COMPACTION_MAX_REQUESTS = 4;
3
+ /** The share of a context window that a request can fill before the loop compacts. */
4
+ const CONTEXT_WINDOW_SHARE = 0.8;
5
+ /**
6
+ * The rules of an agent for a model with `contextWindow`. The budget is the smaller of the declared budget and 80% of
7
+ * the window. The turn-start budget keeps its ratio to the budget. An absent window keeps the declared rules.
8
+ */
9
+ export function rulesForContextWindow(rules, contextWindow) {
10
+ if (contextWindow === undefined)
11
+ return rules;
12
+ const budget = Math.min(rules.budget, Math.floor(contextWindow * CONTEXT_WINDOW_SHARE));
13
+ if (budget === rules.budget)
14
+ return rules;
15
+ return {
16
+ budget,
17
+ ...(rules.turnStartBudget === undefined ? {} : { turnStartBudget: Math.floor((rules.turnStartBudget * budget) / rules.budget) }),
18
+ };
19
+ }
@@ -91,6 +91,15 @@ export interface RunAgentOptions {
91
91
  * prompt cache and a signed thinking block bind to.
92
92
  */
93
93
  readonly toolChoice?: ChatRequest["toolChoice"];
94
+ /**
95
+ * An early cap. Read at the top of each iteration; when it answers true the
96
+ * loop leaves the tool phase as if `maxIterations` were reached and takes the
97
+ * same tool-less wrap-up path, thus a terminal wrapper salvages the run the
98
+ * same way. A host uses it to end a run that a policy has judged to be a
99
+ * loop, for example one the call guard refused too many times, without
100
+ * waiting for the iteration cap or the wall clock.
101
+ */
102
+ readonly stopWhen?: () => boolean;
94
103
  /**
95
104
  * Optional outcome predicate for loop-driving agents whose result is
96
105
  * recorded by a tool into closure state. Checked immediately after each
@@ -153,6 +153,7 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
153
153
  });
154
154
  let iterations = 0;
155
155
  let truncationRecoveries = 0;
156
+ let stoppedEarly = false;
156
157
  // Counted rather than listed: the finish record stays one bounded line for a run of
157
158
  // any length, and "50 iterations, 49 tool calls, 47 of them errored" already separates
158
159
  // a productive long run from a loop stuck retrying one failing call. The per-iteration
@@ -372,9 +373,11 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
372
373
  const records = await active.recordsAfter(conversationView([...messages, markerOf(0)], viewOptions).messages);
373
374
  const durationMs = Math.round(performance.now() - startedAt);
374
375
  messages.push(markerOf(durationMs), ...records);
375
- await giveRound();
376
376
  settled = true;
377
+ // The `done` part goes out before the marker round: a chat turn gives the rounds after a marker a new
378
+ // assistant message, and the `running` and `done` parts of one compaction must stream into the same message.
377
379
  await emitPart({ id, status: "done", tokensBefore, durationMs });
380
+ await giveRound();
378
381
  log.info("conversation compacted", { compactionId: id, trigger, tokensBefore, durationMs });
379
382
  summarizedLast = true;
380
383
  return undefined;
@@ -420,6 +423,13 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
420
423
  if (segment.requestText !== undefined)
421
424
  messages.push(syntheticUserMessage(segment.requestText));
422
425
  for (let k = 0; k < segment.maxRequests; k++) {
426
+ // The early cap ends a segment before the cap as if the cap were reached, thus a run takes its
427
+ // tool-less wrap-up. The segment that closes a capped run is that wrap-up, and it runs to its end.
428
+ if (segment.closesCappedRun !== true && opts.stopWhen?.()) {
429
+ stoppedEarly = true;
430
+ log.warn("run stopped early by the host policy, taking the wrap-up path", { iterations });
431
+ return "capped";
432
+ }
423
433
  const index = segment.firstIndex + k;
424
434
  // The last request of a segment that closes a capped run ends the run,
425
435
  // whatever its reply, thus its `iteration` event is the final one.
@@ -512,6 +522,8 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
512
522
  // abort (user cut the turn) from a plain `max_iterations` cap-out.
513
523
  const reason = reply.finishReason === "aborted" ? "aborted" : "max_iterations";
514
524
  recordAgentRun({ agentId: metricAgentId, iterations, cappedOut: true });
525
+ if (stoppedEarly)
526
+ log.warn("run finished on the early cap", { iterations });
515
527
  logFinish("warn", reason, true);
516
528
  await giveRound();
517
529
  return { messages, finish: { reason, cappedOut: true, truncationRecoveries, ...finishUsage() } };
@@ -552,6 +564,8 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
552
564
  const endCapped = async () => {
553
565
  settleTranscript();
554
566
  recordAgentRun({ agentId: metricAgentId, iterations, cappedOut: true });
567
+ if (stoppedEarly)
568
+ log.warn("run finished on the early cap", { iterations });
555
569
  logFinish("warn", "max_iterations", true);
556
570
  await giveRound();
557
571
  return { messages, finish: { reason: "max_iterations", cappedOut: true, truncationRecoveries, ...finishUsage() } };
@@ -54,7 +54,10 @@ export async function runToTerminal(agent, initial, session, opts, salvage) {
54
54
  salvageTools: salvage.tools.map((t) => t.id),
55
55
  salvageMaxIterations: salvageBudget,
56
56
  });
57
- const salvaged = await continueAgent(agent, first.messages, { text: salvage.nudge, mask: { allow: salvage.tools.map((t) => t.id) }, maxRequests: salvageBudget, stepNamespace: "salvage" }, session, opts);
57
+ // The early cap of the first run must not end the salvage turn, whose whole
58
+ // purpose is to submit after the first run stopped.
59
+ const { stopWhen: _stopWhen, ...continuation } = opts;
60
+ const salvaged = await continueAgent(agent, first.messages, { text: salvage.nudge, mask: { allow: salvage.tools.map((t) => t.id) }, maxRequests: salvageBudget, stepNamespace: "salvage" }, session, continuation);
58
61
  // The continuation is the same logical run as the first attempt, so its
59
62
  // rollups must cover both, or the caller reads the salvage's tokens as the whole cost.
60
63
  //
@@ -102,6 +102,12 @@ export type EmitEvent = {
102
102
  * taken. As a result a host can fall back to its own observation.
103
103
  */
104
104
  readonly durationMs?: number;
105
+ } | {
106
+ /** The display recorder of a chat turn emits it, not the loop. See `MessageStartEvent`. */
107
+ readonly type: "message-start";
108
+ readonly source: EventSource;
109
+ readonly messageId: string;
110
+ readonly role: "user" | "assistant";
105
111
  };
106
112
  /**
107
113
  * A UI presentation event a tool streams to chat — an AI-SDK `data-*`
@@ -18,8 +18,6 @@ export interface ConversationDisplayRecorderOptions {
18
18
  /** The root agent of the turn. Its call path is `[agentId]`, and an event with a longer path is from a sub-agent. */
19
19
  readonly agentId: string;
20
20
  readonly sink: EmitFn;
21
- readonly userMessageId?: string;
22
- readonly assistantMessageId?: string;
23
21
  /** The clock for the times of the sub-agent activity, in milliseconds. The default is the wall clock. */
24
22
  readonly now?: () => number;
25
23
  }
@@ -64,8 +64,10 @@ function compactionDivider(marker) {
64
64
  */
65
65
  export function createConversationDisplayRecorder(options) {
66
66
  // One id for each message of the turn, thus the rounds of a turn replay as one assistant message.
67
- const userMessageId = options.userMessageId ?? randomUUID();
68
- let assistantMessageId = options.assistantMessageId ?? randomUUID();
67
+ const userMessageId = randomUUID();
68
+ let assistantMessageId = randomUUID();
69
+ let userAnnounced = false;
70
+ let announcedAssistantId;
69
71
  const rootSource = { agentId: options.agentId, callPath: [options.agentId] };
70
72
  // The messages of the open round. A later update of a part lands in the next round, and the replay replaces the earlier copy.
71
73
  let round = [];
@@ -103,10 +105,33 @@ export function createConversationDisplayRecorder(options) {
103
105
  }
104
106
  apply(frame);
105
107
  }
108
+ function messageStart(messageId, role) {
109
+ return { type: "message-start", messageId, role, source: rootSource };
110
+ }
111
+ /** The `message-start` of each id that the sink did not get yet. */
112
+ function announcements() {
113
+ const starts = [];
114
+ if (!userAnnounced) {
115
+ userAnnounced = true;
116
+ starts.push(messageStart(userMessageId, "user"));
117
+ }
118
+ if (announcedAssistantId !== assistantMessageId) {
119
+ announcedAssistantId = assistantMessageId;
120
+ starts.push(messageStart(assistantMessageId, "assistant"));
121
+ }
122
+ return starts;
123
+ }
124
+ // A `message-start` goes to the sink only immediately before an event: a host reads the first call of its sink
125
+ // as the start of the turn, and a turn that ends before any event must still get its plain refusal (for example an HTTP 402).
106
126
  const emit = (event) => {
107
127
  if (!finished)
108
128
  record(event);
109
- return options.sink(event);
129
+ const starts = announcements();
130
+ if (starts.length === 0)
131
+ return options.sink(event);
132
+ // The calls start in the order of the stream with no await between them: a later emit that its caller does
133
+ // not await, for example a text delta, must not reach the sink before them.
134
+ return Promise.all([...starts, event].map((next) => options.sink(next))).then(() => undefined);
110
135
  };
111
136
  function takeOpening() {
112
137
  return [{ id: userMessageId, role: "user", parts: [{ type: "text", text: options.userText, state: "done" }] }];
@@ -29,7 +29,7 @@ import type { Logger } from "../lib/logger.js";
29
29
  import { type StoredMessageEnvelope } from "./ai-sdk-message-storage.js";
30
30
  import type { MessagePart } from "../contracts/message.js";
31
31
  import { type ConversationUIMessage, type StoredDisplayEnvelope } from "./conversation-display-storage.js";
32
- /** One stored message, as returned by the display read (`loadAll`). */
32
+ /** One stored message, as returned by the display reads (`loadAll` and `loadPage`). */
33
33
  export interface StoredMessage {
34
34
  readonly seq: number;
35
35
  readonly envelope: StoredMessageEnvelope;
@@ -172,6 +172,20 @@ export interface LoadRecentOptions {
172
172
  /** Keep the first turn of the thread, the seed of a report thread, in front of the latest summary. */
173
173
  readonly keepFirstTurn?: boolean;
174
174
  }
175
+ /** The options of {@link ThreadHistory.loadPage}. */
176
+ export interface LoadPageOptions {
177
+ /** The cursor that the previous page gave. Absent for the newest page. */
178
+ readonly before?: number;
179
+ /** The maximum count of turns in the page. */
180
+ readonly limit: number;
181
+ }
182
+ /** One page of the display read, as {@link ThreadHistory.loadPage} gives it. */
183
+ export interface TurnPage {
184
+ /** The whole turns of the page, oldest-first. */
185
+ readonly turns: StoredMessage[][];
186
+ /** The cursor of the next older page, or `null` when the page holds the oldest row of the thread. */
187
+ readonly before: number | null;
188
+ }
175
189
  /**
176
190
  * The conversation message store. Two methods, by design — no generic row
177
191
  * insert (see the harness-thread-store spec). `threadId` is the conversation scope — one UI thread.
@@ -195,12 +209,8 @@ export interface ThreadHistory {
195
209
  /**
196
210
  * Return a thread's messages oldest-first for UI display, grouped into turns.
197
211
  * It gives each row, and the view of the agent loop is `loadRecent`'s job.
198
- *
199
- * There is no paginated form, because a page could never cost less: the read
200
- * selects and parses every row of the thread and would slice only afterwards,
201
- * so a page saves no query and no parse. It would only truncate the answer,
202
- * which is a way to lose turns and not a way to save work. A reader wanting a
203
- * window takes one here, where it knows what it is bounding.
212
+ * It selects and parses every row of the thread, thus its work grows with the
213
+ * thread. {@link ThreadHistory.loadPage} bounds that work.
204
214
  *
205
215
  * Grouped rather than flat because the grouping is what the read computes and
206
216
  * what a turn count means, and `.flat()` recovers the rows in `seq` order for
@@ -209,6 +219,23 @@ export interface ThreadHistory {
209
219
  * this read already found.
210
220
  */
211
221
  loadAll(threadId: string): ResultAsync<StoredMessage[][], DbError>;
222
+ /**
223
+ * Return the newest whole turns of a thread for UI display, up to `limit` turns, oldest-first.
224
+ * With `before`, it returns the newest whole turns below the turn that the cursor names.
225
+ * The rows are the same as the rows of {@link ThreadHistory.loadAll}.
226
+ *
227
+ * The read selects and parses only the rows of its page, thus its work does not grow with
228
+ * the turns before the page. The page edges inside the thread are turn starts, so the read
229
+ * never truncates a turn, also for a cursor that is not a turn start: the turn of the cursor
230
+ * row is then the newest turn of the page, and it is whole. The rows before the first turn
231
+ * start make one group, and the group counts as a turn.
232
+ *
233
+ * The cursor is the `seq` of the opening row of the oldest turn of the page. A `seq` never
234
+ * moves and a retract removes only the tail, thus a cursor stays correct across a later
235
+ * append and a later retract. A `limit` that is not finite reads as 1, and each other
236
+ * `limit` reads as its integer part with a minimum of 1.
237
+ */
238
+ loadPage(threadId: string, options: LoadPageOptions): ResultAsync<TurnPage, DbError>;
212
239
  /**
213
240
  * Remove the thread's most recent turn — every row from the last
214
241
  * genuine-user-start `seq` onward, and its turn record — in a single transaction.
@@ -224,6 +224,102 @@ function closeTurnRecord(client, threadId, startSeq, close) {
224
224
  SET status = $3, reason = $4, reported_usage = $5::jsonb, turn_duration_ms = $6, closed_at = NOW()
225
225
  WHERE thread_id = $1 AND start_seq = $2 AND status = 'open'`, [threadId, startSeq, close.status, close.reason === undefined ? null : stripNulCharacters(close.reason), rollup, close.turnDurationMs ?? null])).map(() => undefined);
226
226
  }
227
+ /** The columns of a display-read row, and the join that the turn record needs. Both display reads select them. */
228
+ const STORED_ROW_COLUMNS = `messages.seq::text AS seq, messages.message_envelope, messages.display_envelope, messages.reported_usage,
229
+ messages.turn_duration_ms::text AS turn_duration_ms, messages.author, messages.created_at,
230
+ turns.status AS turn_status, turns.reason AS turn_reason, turns.reported_usage AS turn_usage,
231
+ turns.turn_duration_ms::text AS turn_record_duration_ms`;
232
+ function storedRowFrom(turnJoinBounds = "") {
233
+ return `FROM messages
234
+ LEFT JOIN cortex_thread_turns turns ON turns.thread_id = messages.thread_id AND turns.start_seq = messages.seq${turnJoinBounds}`;
235
+ }
236
+ /** The largest `bigint`, the bound of a cursor that the caller did not give. */
237
+ const MAX_SEQ = "9223372036854775807";
238
+ /** The smallest `bigint`, below every `seq`. */
239
+ const MIN_SEQ = "'-9223372036854775808'";
240
+ const PAGE_LOWER_BOUND = `COALESCE((SELECT seq FROM page_lower), ${MIN_SEQ}::bigint)`;
241
+ const PAGE_UPPER_BOUND = `COALESCE((SELECT seq FROM page_upper), ${MAX_SEQ}::bigint)`;
242
+ /**
243
+ * The statement of one page of the display read (`$1` thread, `$2` cursor or `NULL`, `$3` limit).
244
+ *
245
+ * One statement reads one snapshot, thus an append or a retract cannot add a turn between the
246
+ * read of the page edges and the read of the rows. The parts:
247
+ *
248
+ * - `page_walk`: a walk down from the cursor, one row for each step, that counts the turn starts
249
+ * and stops at the `$3`-th. Each step is a `LIMIT 1` read of the primary key `(thread_id, seq)`
250
+ * that evaluates the start predicate in the select list.
251
+ * - `page_lower`: the row where the walk reached `$3` starts, else `NULL`. A page that is not
252
+ * full reaches the start of the thread and holds the rows before the first turn start.
253
+ * - `page_climb` and `page_upper`: a walk up from the cursor to the first turn start at or above
254
+ * it, else `NULL`. For a cursor that a page gave this is the cursor row. For a cursor inside a
255
+ * turn it is the start of the next turn, thus the turn of the cursor stays whole.
256
+ * - `page_next`: `page_lower` when a row lies below it. The `EXISTS` reads no envelope.
257
+ *
258
+ * The start predicate is never a `WHERE` condition of a scan that has an `ORDER BY` and a `LIMIT`.
259
+ * The planner has no statistics for a JSON path, so it estimates that very few rows match. It then
260
+ * chooses a bitmap scan and a sort of each row of the thread, and it evaluates the predicate on the
261
+ * envelope of each row (a tool result can be many KB). A walk of `LIMIT 1` reads of the key does
262
+ * not depend on that estimate, and its work grows with the rows of the page.
263
+ *
264
+ * An absent cursor, `page_lower`, and `page_upper` each become a `bigint` extreme through
265
+ * `COALESCE`, never a `NULL` in a comparison: `seq >= NULL` matches no row, and a form such as
266
+ * `($2 IS NULL OR seq < $2)` is a filter, which would visit each newer row. Each bound is an
267
+ * index condition on `(thread_id, seq)`. The join to the turn records carries the same bounds,
268
+ * because the planner estimates a bounded range as a fixed fraction of the thread and then reads
269
+ * each turn record of the thread through a hash join.
270
+ */
271
+ const PAGE_SQL = `WITH RECURSIVE page_walk(seq, starts) AS (
272
+ SELECT COALESCE($2::bigint, ${MAX_SEQ}::bigint), 0::bigint
273
+ UNION ALL
274
+ SELECT below.seq, page_walk.starts + below.is_start::int
275
+ FROM page_walk
276
+ CROSS JOIN LATERAL (
277
+ SELECT messages.seq, COALESCE(${GENUINE_USER_START_SQL}, false) AS is_start FROM messages
278
+ WHERE messages.thread_id = $1 AND messages.seq < page_walk.seq
279
+ ORDER BY messages.seq DESC
280
+ LIMIT 1
281
+ ) below
282
+ WHERE page_walk.starts < $3::bigint
283
+ ),
284
+ page_lower AS (
285
+ SELECT (SELECT page_walk.seq FROM page_walk WHERE page_walk.starts = $3::bigint) AS seq
286
+ ),
287
+ page_climb(seq, is_start) AS (
288
+ SELECT above.seq, above.is_start FROM (
289
+ SELECT messages.seq, COALESCE(${GENUINE_USER_START_SQL}, false) AS is_start FROM messages
290
+ WHERE messages.thread_id = $1 AND messages.seq >= COALESCE($2::bigint, ${MAX_SEQ}::bigint)
291
+ ORDER BY messages.seq ASC
292
+ LIMIT 1
293
+ ) above
294
+ UNION ALL
295
+ SELECT above.seq, above.is_start
296
+ FROM page_climb
297
+ CROSS JOIN LATERAL (
298
+ SELECT messages.seq, COALESCE(${GENUINE_USER_START_SQL}, false) AS is_start FROM messages
299
+ WHERE messages.thread_id = $1 AND messages.seq > page_climb.seq
300
+ ORDER BY messages.seq ASC
301
+ LIMIT 1
302
+ ) above
303
+ WHERE NOT page_climb.is_start
304
+ ),
305
+ page_upper AS (
306
+ SELECT page_climb.seq FROM page_climb WHERE page_climb.is_start
307
+ ),
308
+ page_next AS (
309
+ SELECT page_lower.seq FROM page_lower
310
+ WHERE page_lower.seq IS NOT NULL
311
+ AND EXISTS (SELECT 1 FROM messages WHERE thread_id = $1 AND seq < page_lower.seq)
312
+ )
313
+ SELECT ${STORED_ROW_COLUMNS}, (SELECT seq::text FROM page_next) AS next_before
314
+ ${storedRowFrom(` AND turns.start_seq >= ${PAGE_LOWER_BOUND} AND turns.start_seq < ${PAGE_UPPER_BOUND}`)}
315
+ WHERE messages.thread_id = $1
316
+ AND messages.seq >= ${PAGE_LOWER_BOUND}
317
+ AND messages.seq < ${PAGE_UPPER_BOUND}
318
+ ORDER BY messages.seq ASC`;
319
+ /** The limit that the page statement receives: an integer, at least 1, and a bigint-safe size. */
320
+ function pageLimit(limit) {
321
+ return Number.isFinite(limit) ? Math.min(Math.max(1, Math.trunc(limit)), Number.MAX_SAFE_INTEGER) : 1;
322
+ }
227
323
  /**
228
324
  * Create a `ThreadHistory` bound to a Postgres pool — a factory closure
229
325
  * capturing `pool` (dependency injection per the harness-durable-runtime spec). The `messages` table is
@@ -296,59 +392,72 @@ export function createThreadHistory(pool, logger) {
296
392
  return view.messages;
297
393
  });
298
394
  }
299
- // Display reads are turn-bounded, not row-bounded. A serial-tool assistant
300
- // turn is persisted as one row per step (plus its tool_result `user` rows),
301
- // so a row-windowed page could split a turn — truncating the trailing
302
- // report card/text out of the page the UI fetches. Read the thread and group
303
- // into turns, so a turn always reloads intact. Threads are conversation-scoped
304
- // and bounded, so reading every row here matches `loadRecent`'s existing
395
+ // A display read is turn-bounded, not row-bounded. A serial-tool assistant
396
+ // turn is persisted as one row per step (plus its tool_result rows), so a
397
+ // row-windowed read could split a turn and truncate the trailing report
398
+ // card or text out of what the UI fetches. Both reads group rows into turns,
399
+ // so a turn always reloads intact.
400
+ // Spread rather than `usage: r.reported_usage ?? undefined`, so a row with
401
+ // no rollup carries no `usage` KEY at all — absent, not present-and-undefined.
402
+ // The duration and the author obey the same rule, each from its own column.
403
+ // The creation time is unconditional: its column is NOT NULL, thus it has no
404
+ // absence to represent.
405
+ async function toStoredMessage(threadId, r) {
406
+ const envelope = parseStoredMessageEnvelope(r.message_envelope, `${threadId}/${r.seq}`);
407
+ const displayEnvelope = r.display_envelope == null ? undefined : await parseStoredDisplayEnvelope(r.display_envelope, `${threadId}/${r.seq}/display`, logger);
408
+ const turn = r.turn_status === null
409
+ ? undefined
410
+ : {
411
+ status: r.turn_status,
412
+ ...(r.turn_reason === null ? {} : { reason: r.turn_reason }),
413
+ ...(r.turn_usage === null ? {} : { usage: r.turn_usage }),
414
+ ...(r.turn_record_duration_ms === null ? {} : { durationMs: Number(r.turn_record_duration_ms) }),
415
+ };
416
+ return {
417
+ seq: Number(r.seq),
418
+ envelope,
419
+ message: envelope.message,
420
+ ...(displayEnvelope ? { displayEnvelope } : {}),
421
+ ...(r.reported_usage === null ? {} : { usage: r.reported_usage }),
422
+ ...(r.turn_duration_ms === null ? {} : { durationMs: Number(r.turn_duration_ms) }),
423
+ ...(turn === undefined ? {} : { turn }),
424
+ ...(r.author === null ? {} : { author: r.author }),
425
+ createdAt: r.created_at,
426
+ };
427
+ }
428
+ async function toTurns(threadId, rows) {
429
+ const stored = await Promise.all(rows.map((row) => toStoredMessage(threadId, row)));
430
+ return groupTurns(stored, (row) => isGenuineUserStart(row.message));
431
+ }
305
432
  async function readTurns(threadId) {
306
433
  const { rows } = await pool.query(
307
434
  // ORDER BY must qualify `messages.seq` — a bare `seq` would bind to the
308
435
  // `seq::text AS seq` output alias (Postgres resolves an unqualified
309
436
  // ORDER BY name to the output column), sorting the bigint as text:
310
437
  // "10" before "2". The qualified name forces the bigint column.
311
- `SELECT messages.seq::text AS seq, messages.message_envelope, messages.display_envelope, messages.reported_usage,
312
- messages.turn_duration_ms::text AS turn_duration_ms, messages.author, messages.created_at,
313
- turns.status AS turn_status, turns.reason AS turn_reason, turns.reported_usage AS turn_usage,
314
- turns.turn_duration_ms::text AS turn_record_duration_ms
315
- FROM messages
316
- LEFT JOIN cortex_thread_turns turns ON turns.thread_id = messages.thread_id AND turns.start_seq = messages.seq
438
+ `SELECT ${STORED_ROW_COLUMNS}
439
+ ${storedRowFrom()}
317
440
  WHERE messages.thread_id = $1
318
441
  ORDER BY messages.seq ASC`, [threadId]);
319
- // Spread rather than `usage: r.reported_usage ?? undefined`, so a row with
320
- // no rollup carries no `usage` KEY at all — absent, not present-and-undefined.
321
- // The duration and the author obey the same rule, each from its own column.
322
- // The creation time is unconditional: its column is NOT NULL, thus it has no
323
- // absence to represent.
324
- const stored = await Promise.all(rows.map(async (r) => {
325
- const envelope = parseStoredMessageEnvelope(r.message_envelope, `${threadId}/${r.seq}`);
326
- const displayEnvelope = r.display_envelope == null ? undefined : await parseStoredDisplayEnvelope(r.display_envelope, `${threadId}/${r.seq}/display`, logger);
327
- const turn = r.turn_status === null
328
- ? undefined
329
- : {
330
- status: r.turn_status,
331
- ...(r.turn_reason === null ? {} : { reason: r.turn_reason }),
332
- ...(r.turn_usage === null ? {} : { usage: r.turn_usage }),
333
- ...(r.turn_record_duration_ms === null ? {} : { durationMs: Number(r.turn_record_duration_ms) }),
334
- };
335
- return {
336
- seq: Number(r.seq),
337
- envelope,
338
- message: envelope.message,
339
- ...(displayEnvelope ? { displayEnvelope } : {}),
340
- ...(r.reported_usage === null ? {} : { usage: r.reported_usage }),
341
- ...(r.turn_duration_ms === null ? {} : { durationMs: Number(r.turn_duration_ms) }),
342
- ...(turn === undefined ? {} : { turn }),
343
- ...(r.author === null ? {} : { author: r.author }),
344
- createdAt: r.created_at,
345
- };
346
- }));
347
- return groupTurns(stored, (row) => isGenuineUserStart(row.message));
442
+ return toTurns(threadId, rows);
443
+ }
444
+ async function readPage(threadId, options) {
445
+ const { rows } = await pool.query(PAGE_SQL, [
446
+ threadId,
447
+ options.before === undefined ? null : String(options.before),
448
+ pageLimit(options.limit),
449
+ ]);
450
+ // The cursor rides each row, and an empty page has no row. An empty page has no cursor either:
451
+ // `page_lower` is set only when its row is in the page.
452
+ const next = rows[0]?.next_before ?? null;
453
+ return { turns: await toTurns(threadId, rows), before: next === null ? null : Number(next) };
348
454
  }
349
455
  function loadAll(threadId) {
350
456
  return tryQuery("thread-history.loadAll", () => readTurns(threadId));
351
457
  }
458
+ function loadPage(threadId, options) {
459
+ return tryQuery("thread-history.loadPage", () => readPage(threadId, options));
460
+ }
352
461
  function retractLastTurn(threadId) {
353
462
  return withTransaction(pool, "thread-history.retractLastTurn", (client) =>
354
463
  // Take the SAME per-thread advisory lock `appendTurn` takes, and take it
@@ -426,5 +535,5 @@ export function createThreadHistory(pool, logger) {
426
535
  AND seq > $2::bigint
427
536
  AND ${GENUINE_USER_START_SQL}`, [threadId, seq])).map(({ rows }) => Number(rows[0].turns));
428
537
  }
429
- return { appendTurn, writeTurn, loadRecent, loadAll, retractLastTurn, latestSeq, latestTurnAt, countUserTurnsAfter };
538
+ return { appendTurn, writeTurn, loadRecent, loadAll, loadPage, retractLastTurn, latestSeq, latestTurnAt, countUserTurnsAfter };
430
539
  }