@inflexa-ai/harness 0.44.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 (187) 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 +51 -0
  10. package/dist/app/approve-plan.js +84 -0
  11. package/dist/app/chat-turn.js +6 -3
  12. package/dist/app/data-profile-view.d.ts +1 -1
  13. package/dist/app/run-activity.js +4 -6
  14. package/dist/config/environment-stores.d.ts +4 -2
  15. package/dist/contracts/chat-events.d.ts +14 -2
  16. package/dist/contracts/chat-events.js +1 -1
  17. package/dist/contracts/chat-frame.d.ts +7 -0
  18. package/dist/contracts/chat-frame.js +24 -6
  19. package/dist/contracts/chat-parts.d.ts +73 -2
  20. package/dist/contracts/chat-parts.js +3 -1
  21. package/dist/contracts/index.d.ts +4 -2
  22. package/dist/contracts/index.js +1 -0
  23. package/dist/contracts/part-registry.js +3 -0
  24. package/dist/contracts/schemas/chat-events.d.ts +38 -0
  25. package/dist/contracts/schemas/chat-events.js +8 -0
  26. package/dist/contracts/schemas/chat-parts.d.ts +239 -15
  27. package/dist/contracts/schemas/chat-parts.js +80 -12
  28. package/dist/contracts/schemas/index.d.ts +2 -2
  29. package/dist/contracts/schemas/index.js +2 -2
  30. package/dist/contracts/subagent-activity.d.ts +12 -0
  31. package/dist/contracts/subagent-activity.js +76 -0
  32. package/dist/execution/artifact-metadata.js +1 -3
  33. package/dist/execution/dbos-workflow-purger.d.ts +2 -0
  34. package/dist/execution/dbos-workflow-purger.js +21 -14
  35. package/dist/execution/run-canceler.d.ts +33 -11
  36. package/dist/execution/run-canceler.js +130 -28
  37. package/dist/execution/run-end-record.d.ts +30 -0
  38. package/dist/execution/run-end-record.js +27 -0
  39. package/dist/execution/run-event-parts.d.ts +6 -5
  40. package/dist/execution/run-event-parts.js +6 -5
  41. package/dist/execution/run-event-stream.d.ts +18 -6
  42. package/dist/execution/run-event-stream.js +67 -17
  43. package/dist/execution/run-synthesis.d.ts +21 -0
  44. package/dist/execution/run-synthesis.js +77 -3
  45. package/dist/index.d.ts +28 -10
  46. package/dist/index.js +26 -2
  47. package/dist/lib/hooks.d.ts +1 -1
  48. package/dist/lib/metrics.d.ts +2 -2
  49. package/dist/loop/call-guard.d.ts +50 -0
  50. package/dist/loop/call-guard.js +90 -0
  51. package/dist/loop/compaction.d.ts +5 -0
  52. package/dist/loop/compaction.js +17 -0
  53. package/dist/loop/run-agent.d.ts +9 -0
  54. package/dist/loop/run-agent.js +16 -1
  55. package/dist/loop/run-to-terminal.js +4 -1
  56. package/dist/loop/types.d.ts +8 -0
  57. package/dist/memory/card-builders.d.ts +11 -0
  58. package/dist/memory/card-builders.js +35 -21
  59. package/dist/memory/conversation-display-recorder.d.ts +2 -2
  60. package/dist/memory/conversation-display-recorder.js +38 -4
  61. package/dist/memory/conversation-display-storage.d.ts +9 -2
  62. package/dist/memory/conversation-display-storage.js +18 -3
  63. package/dist/memory/thread-history.d.ts +42 -8
  64. package/dist/memory/thread-history.js +164 -45
  65. package/dist/memory/thread-store.d.ts +13 -0
  66. package/dist/memory/thread-store.js +15 -1
  67. package/dist/prompts/briefing.d.ts +57 -3
  68. package/dist/prompts/briefing.js +129 -2
  69. package/dist/prompts/conversation.d.ts +1 -1
  70. package/dist/prompts/conversation.js +3 -1
  71. package/dist/prompts/planner.js +13 -2
  72. package/dist/provenance/seam.d.ts +39 -20
  73. package/dist/provenance/seam.js +7 -5
  74. package/dist/providers/ai-sdk.js +93 -11
  75. package/dist/providers/types.d.ts +6 -0
  76. package/dist/reference-data/catalog.js +141 -2
  77. package/dist/report-model/authoring-grammar.d.ts +26 -26
  78. package/dist/report-model/pin-snapshot.d.ts +1 -1
  79. package/dist/report-model/pin-snapshot.js +2 -50
  80. package/dist/runtime/assemble.d.ts +8 -1
  81. package/dist/runtime/assemble.js +11 -0
  82. package/dist/runtime/boot.d.ts +3 -0
  83. package/dist/runtime/boot.js +10 -1
  84. package/dist/sandbox/image-packages.d.ts +26 -4
  85. package/dist/sandbox/image-packages.js +18 -4
  86. package/dist/sandbox/sandbox-step-translate.d.ts +23 -0
  87. package/dist/sandbox/sandbox-step-translate.js +24 -0
  88. package/dist/schemas/artifact-manifest.d.ts +1 -1
  89. package/dist/schemas/artifact-manifest.js +11 -9
  90. package/dist/schemas/data-profile-schemas.d.ts +12 -12
  91. package/dist/schemas/plan-schemas.d.ts +75 -21
  92. package/dist/schemas/plan-schemas.js +1 -1
  93. package/dist/schemas/run-synthesis.d.ts +2 -2
  94. package/dist/schemas/workflow-state.d.ts +113 -0
  95. package/dist/schemas/workflow-state.js +54 -0
  96. package/dist/state/analyses-metrics.d.ts +79 -7
  97. package/dist/state/analyses-metrics.js +141 -6
  98. package/dist/state/artifacts.d.ts +66 -3
  99. package/dist/state/artifacts.js +85 -7
  100. package/dist/state/data-profile.d.ts +4 -2
  101. package/dist/state/data-profile.js +7 -5
  102. package/dist/state/index.d.ts +9 -8
  103. package/dist/state/index.js +5 -5
  104. package/dist/state/migrations/20261012120000_index_the_runs_by_start.d.ts +8 -0
  105. package/dist/state/migrations/20261012120000_index_the_runs_by_start.js +12 -0
  106. package/dist/state/migrations/20261013120000_index_the_ended_runs_and_the_recent_threads.d.ts +8 -0
  107. package/dist/state/migrations/20261013120000_index_the_ended_runs_and_the_recent_threads.js +13 -0
  108. package/dist/state/migrations/20261013180000_narrow_the_active_run_index.d.ts +8 -0
  109. package/dist/state/migrations/20261013180000_narrow_the_active_run_index.js +13 -0
  110. package/dist/state/migrations/20261014120000_index_the_plans_by_parent_and_the_runs_by_plan.d.ts +8 -0
  111. package/dist/state/migrations/20261014120000_index_the_plans_by_parent_and_the_runs_by_plan.js +13 -0
  112. package/dist/state/migrations/index.js +8 -0
  113. package/dist/state/plans.d.ts +21 -0
  114. package/dist/state/plans.js +25 -0
  115. package/dist/state/runs.d.ts +79 -14
  116. package/dist/state/runs.js +150 -33
  117. package/dist/state/schema.d.ts +2 -0
  118. package/dist/state/step-executions.d.ts +9 -0
  119. package/dist/state/step-executions.js +22 -0
  120. package/dist/tasks/data-profile-activity.d.ts +7 -1
  121. package/dist/tasks/data-profile-activity.js +18 -9
  122. package/dist/tasks/data-profile.d.ts +15 -1
  123. package/dist/tasks/data-profile.js +16 -11
  124. package/dist/tools/ad-hoc-router.d.ts +2 -0
  125. package/dist/tools/ad-hoc-router.js +18 -2
  126. package/dist/tools/approval/gateway.js +28 -5
  127. package/dist/tools/capability-data.d.ts +36 -0
  128. package/dist/tools/capability-data.js +73 -0
  129. package/dist/tools/display/show-user.d.ts +1 -1
  130. package/dist/tools/execute-analysis.d.ts +80 -1
  131. package/dist/tools/execute-analysis.js +352 -110
  132. package/dist/tools/knowledge/check.d.ts +60 -0
  133. package/dist/tools/knowledge/check.js +92 -0
  134. package/dist/tools/knowledge/client.d.ts +382 -0
  135. package/dist/tools/knowledge/client.js +302 -0
  136. package/dist/tools/knowledge/environment.d.ts +78 -0
  137. package/dist/tools/knowledge/environment.js +105 -0
  138. package/dist/tools/knowledge/index.d.ts +49 -0
  139. package/dist/tools/knowledge/index.js +70 -0
  140. package/dist/tools/knowledge/local-slots.d.ts +62 -0
  141. package/dist/tools/knowledge/local-slots.js +177 -0
  142. package/dist/tools/knowledge/pin.d.ts +14 -0
  143. package/dist/tools/knowledge/pin.js +17 -0
  144. package/dist/tools/knowledge/recommend.d.ts +92 -0
  145. package/dist/tools/knowledge/recommend.js +98 -0
  146. package/dist/tools/knowledge/situation.d.ts +143 -0
  147. package/dist/tools/knowledge/situation.js +164 -0
  148. package/dist/tools/knowledge/skeleton.d.ts +69 -0
  149. package/dist/tools/knowledge/skeleton.js +202 -0
  150. package/dist/tools/knowledge/template.d.ts +137 -0
  151. package/dist/tools/knowledge/template.js +267 -0
  152. package/dist/tools/research/generate-plan.d.ts +23 -0
  153. package/dist/tools/research/generate-plan.js +203 -12
  154. package/dist/tools/research/guarded-address.d.ts +20 -0
  155. package/dist/tools/research/guarded-address.js +148 -0
  156. package/dist/tools/research/inspect-data-profile.d.ts +1 -1
  157. package/dist/tools/research/inspect-run.d.ts +1 -1
  158. package/dist/tools/research/inspect-run.js +7 -7
  159. package/dist/tools/research/searxng-engine.d.ts +40 -0
  160. package/dist/tools/research/searxng-engine.js +120 -0
  161. package/dist/tools/research/web-fetch.d.ts +124 -0
  162. package/dist/tools/research/web-fetch.js +627 -0
  163. package/dist/tools/research/web-search.d.ts +75 -0
  164. package/dist/tools/research/web-search.js +112 -0
  165. package/dist/tools/sandbox/list-available-packages.d.ts +7 -4
  166. package/dist/tools/sandbox/list-available-packages.js +12 -4
  167. package/dist/tools/sandbox/list-available-refs.d.ts +21 -0
  168. package/dist/tools/sandbox/list-available-refs.js +60 -3
  169. package/dist/tools/sandbox/report-blocker.js +5 -1
  170. package/dist/tools/terminal-text.d.ts +11 -0
  171. package/dist/tools/terminal-text.js +19 -0
  172. package/dist/tools/workspace/decision-record.d.ts +81 -0
  173. package/dist/tools/workspace/decision-record.js +117 -0
  174. package/dist/tools/workspace/edit-file.d.ts +3 -1
  175. package/dist/tools/workspace/edit-file.js +20 -2
  176. package/dist/tools/workspace/execute-command.d.ts +17 -0
  177. package/dist/tools/workspace/execute-command.js +28 -1
  178. package/dist/tools/workspace/index.d.ts +1 -0
  179. package/dist/tools/workspace/index.js +1 -0
  180. package/dist/tools/workspace/mutator.d.ts +1 -1
  181. package/dist/workflows/execute-analysis.d.ts +62 -21
  182. package/dist/workflows/execute-analysis.js +307 -79
  183. package/dist/workflows/plan/graph.d.ts +16 -0
  184. package/dist/workflows/plan/graph.js +36 -0
  185. package/dist/workflows/sandbox-step.d.ts +12 -0
  186. package/dist/workflows/sandbox-step.js +14 -12
  187. package/package.json +5 -1
@@ -29,7 +29,7 @@ import { hasReportedUsage } from "../loop/metrics.js";
29
29
  import { countTokens } from "./count-tokens.js";
30
30
  import { conversationView, groupTurns, isGenuineUserStart } from "./conversation-view.js";
31
31
  import { HARNESS_PROVIDER_NAMESPACE, SYNTHETIC_MESSAGE_KEY, envelopeMessage, parseStoredMessageEnvelope, syntheticRecordMessage, } from "./ai-sdk-message-storage.js";
32
- import { envelopeDisplayMessages, parseStoredDisplayEnvelope } from "./conversation-display-storage.js";
32
+ import { conversationDisplayPart, envelopeDisplayMessages, parseStoredDisplayEnvelope, } from "./conversation-display-storage.js";
33
33
  /** A resolved `ok(undefined)` ResultAsync — the empty/seed transaction step. */
34
34
  function okVoid() {
35
35
  return new ResultAsync(Promise.resolve(ok(undefined)));
@@ -44,11 +44,21 @@ function okVoid() {
44
44
  * never shown. The harness owns the record-to-`system` mapping for the same
45
45
  * reason it owns the marker — a host that hand-assembled either could produce a
46
46
  * message the turn-boundary predicates fail to recognise.
47
+ *
48
+ * With `displayParts`, the display message holds those parts in place of the
49
+ * text part, and the model message is still the record of `text`. The text then
50
+ * stays for the model alone.
47
51
  */
48
- export function conversationRecordTurn(text) {
52
+ export function conversationRecordTurn(text, displayParts) {
49
53
  return {
50
54
  modelMessages: [syntheticRecordMessage(text)],
51
- displayMessages: [{ id: randomUUID(), role: "system", parts: [{ type: "text", text, state: "done" }] }],
55
+ displayMessages: [
56
+ {
57
+ id: randomUUID(),
58
+ role: "system",
59
+ parts: displayParts !== undefined && displayParts.length > 0 ? displayParts.map(conversationDisplayPart) : [{ type: "text", text, state: "done" }],
60
+ },
61
+ ],
52
62
  };
53
63
  }
54
64
  /**
@@ -214,6 +224,102 @@ function closeTurnRecord(client, threadId, startSeq, close) {
214
224
  SET status = $3, reason = $4, reported_usage = $5::jsonb, turn_duration_ms = $6, closed_at = NOW()
215
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);
216
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
+ }
217
323
  /**
218
324
  * Create a `ThreadHistory` bound to a Postgres pool — a factory closure
219
325
  * capturing `pool` (dependency injection per the harness-durable-runtime spec). The `messages` table is
@@ -286,59 +392,72 @@ export function createThreadHistory(pool, logger) {
286
392
  return view.messages;
287
393
  });
288
394
  }
289
- // Display reads are turn-bounded, not row-bounded. A serial-tool assistant
290
- // turn is persisted as one row per step (plus its tool_result `user` rows),
291
- // so a row-windowed page could split a turn — truncating the trailing
292
- // report card/text out of the page the UI fetches. Read the thread and group
293
- // into turns, so a turn always reloads intact. Threads are conversation-scoped
294
- // 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
+ }
295
432
  async function readTurns(threadId) {
296
433
  const { rows } = await pool.query(
297
434
  // ORDER BY must qualify `messages.seq` — a bare `seq` would bind to the
298
435
  // `seq::text AS seq` output alias (Postgres resolves an unqualified
299
436
  // ORDER BY name to the output column), sorting the bigint as text:
300
437
  // "10" before "2". The qualified name forces the bigint column.
301
- `SELECT messages.seq::text AS seq, messages.message_envelope, messages.display_envelope, messages.reported_usage,
302
- messages.turn_duration_ms::text AS turn_duration_ms, messages.author, messages.created_at,
303
- turns.status AS turn_status, turns.reason AS turn_reason, turns.reported_usage AS turn_usage,
304
- turns.turn_duration_ms::text AS turn_record_duration_ms
305
- FROM messages
306
- 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()}
307
440
  WHERE messages.thread_id = $1
308
441
  ORDER BY messages.seq ASC`, [threadId]);
309
- // Spread rather than `usage: r.reported_usage ?? undefined`, so a row with
310
- // no rollup carries no `usage` KEY at all — absent, not present-and-undefined.
311
- // The duration and the author obey the same rule, each from its own column.
312
- // The creation time is unconditional: its column is NOT NULL, thus it has no
313
- // absence to represent.
314
- const stored = await Promise.all(rows.map(async (r) => {
315
- const envelope = parseStoredMessageEnvelope(r.message_envelope, `${threadId}/${r.seq}`);
316
- const displayEnvelope = r.display_envelope == null ? undefined : await parseStoredDisplayEnvelope(r.display_envelope, `${threadId}/${r.seq}/display`, logger);
317
- const turn = r.turn_status === null
318
- ? undefined
319
- : {
320
- status: r.turn_status,
321
- ...(r.turn_reason === null ? {} : { reason: r.turn_reason }),
322
- ...(r.turn_usage === null ? {} : { usage: r.turn_usage }),
323
- ...(r.turn_record_duration_ms === null ? {} : { durationMs: Number(r.turn_record_duration_ms) }),
324
- };
325
- return {
326
- seq: Number(r.seq),
327
- envelope,
328
- message: envelope.message,
329
- ...(displayEnvelope ? { displayEnvelope } : {}),
330
- ...(r.reported_usage === null ? {} : { usage: r.reported_usage }),
331
- ...(r.turn_duration_ms === null ? {} : { durationMs: Number(r.turn_duration_ms) }),
332
- ...(turn === undefined ? {} : { turn }),
333
- ...(r.author === null ? {} : { author: r.author }),
334
- createdAt: r.created_at,
335
- };
336
- }));
337
- 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) };
338
454
  }
339
455
  function loadAll(threadId) {
340
456
  return tryQuery("thread-history.loadAll", () => readTurns(threadId));
341
457
  }
458
+ function loadPage(threadId, options) {
459
+ return tryQuery("thread-history.loadPage", () => readPage(threadId, options));
460
+ }
342
461
  function retractLastTurn(threadId) {
343
462
  return withTransaction(pool, "thread-history.retractLastTurn", (client) =>
344
463
  // Take the SAME per-thread advisory lock `appendTurn` takes, and take it
@@ -416,5 +535,5 @@ export function createThreadHistory(pool, logger) {
416
535
  AND seq > $2::bigint
417
536
  AND ${GENUINE_USER_START_SQL}`, [threadId, seq])).map(({ rows }) => Number(rows[0].turns));
418
537
  }
419
- return { appendTurn, writeTurn, loadRecent, loadAll, retractLastTurn, latestSeq, latestTurnAt, countUserTurnsAfter };
538
+ return { appendTurn, writeTurn, loadRecent, loadAll, loadPage, retractLastTurn, latestSeq, latestTurnAt, countUserTurnsAfter };
420
539
  }
@@ -169,6 +169,12 @@ export interface ListThreadsInput {
169
169
  /** Narrow to one thread's direct children, exact match. Same polarity as `type`: omitting it narrows nothing. */
170
170
  readonly parentThreadId?: string;
171
171
  }
172
+ export interface ListRecentThreadsInput {
173
+ /** The most threads to give. It clamps to 1 to 200, the bounds of a page of `listThreads`. */
174
+ readonly limit: number;
175
+ /** Narrow to one kind of session, exact match. Omitting it narrows nothing. */
176
+ readonly type?: ThreadType;
177
+ }
172
178
  export interface ThreadPage {
173
179
  readonly threads: Thread[];
174
180
  readonly total: number;
@@ -247,6 +253,13 @@ export interface ThreadStore {
247
253
  * always describe whichever set the page was drawn from.
248
254
  */
249
255
  listThreads(input: ListThreadsInput): ResultAsync<ThreadPage, DbError>;
256
+ /**
257
+ * The newest live threads over every analysis, newest-updated first, with
258
+ * `thread_id` breaking a tie in ascending order. One statement that walks
259
+ * the live-row index on the update time. `type` narrows the result, and an
260
+ * archived thread is never in it.
261
+ */
262
+ listRecentThreads(input: ListRecentThreadsInput): ResultAsync<Thread[], DbError>;
250
263
  }
251
264
  /**
252
265
  * Create a `ThreadStore` bound to a Postgres pool — a factory closure
@@ -366,5 +366,19 @@ export function createThreadStore(pool) {
366
366
  }));
367
367
  });
368
368
  }
369
- return { createThread, getThread, updateTitle, setAutoTitle, archiveThread, unarchiveThread, purgeThread, listThreads };
369
+ function listRecentThreads(input) {
370
+ const limit = Math.min(Math.max(input.limit, 1), MAX_PER_PAGE);
371
+ const values = [limit];
372
+ let typePredicate = "";
373
+ if (input.type !== undefined) {
374
+ values.push(input.type);
375
+ typePredicate = " AND thread_type = $2";
376
+ }
377
+ return tryQuery("thread-store.listRecentThreads", () => pool.query(`SELECT ${THREAD_COLUMNS}
378
+ FROM cortex_analysis_threads
379
+ WHERE deleted_at IS NULL${typePredicate}
380
+ ORDER BY updated_at DESC, thread_id
381
+ LIMIT $1`, values)).map(({ rows }) => rows.map(toThread));
382
+ }
383
+ return { createThread, getThread, updateTitle, setAutoTitle, archiveThread, unarchiveThread, purgeThread, listThreads, listRecentThreads };
370
384
  }
@@ -24,14 +24,20 @@
24
24
  import { type WorkingMemory } from "../memory/working-memory.js";
25
25
  import type { DataProfileResult } from "../state/data-profile.js";
26
26
  import type { AnalysisStep } from "../schemas/workflow-state.js";
27
+ import type { TemplateParameter } from "../tools/knowledge/client.js";
28
+ import type { TemplateBindingValue } from "../tools/knowledge/template.js";
27
29
  /** Completed dependencies rendered as upstream blocks; the tail is counted, not rendered. */
28
30
  export declare const MAX_UPSTREAM_DEPS = 5;
29
31
  /** Characters of each dependency's summary carried in the seed — the gist, not the document. */
30
32
  export declare const UPSTREAM_SUMMARY_MAX_CHARS = 500;
31
33
  /** Artifact paths listed per dependency; the rest are reachable from its output directory. */
32
34
  export declare const MAX_UPSTREAM_ARTIFACTS = 8;
35
+ /** Adaptable slots rendered in the Template contract section; the tail is counted, not rendered. */
36
+ export declare const MAX_TEMPLATE_SLOTS = 24;
37
+ /** Characters of one slot description carried in the seed — the constraint, not the manual. */
38
+ export declare const TEMPLATE_SLOT_DESCRIPTION_MAX_CHARS = 160;
33
39
  /** Fields whose content is agent-facing and MUST appear in the rendered task. */
34
- export declare const STEP_TASK_FIELDS: readonly ["name", "question", "description", "context", "constraints", "acceptance_criteria", "caveats"];
40
+ export declare const STEP_TASK_FIELDS: readonly ["name", "question", "description", "context", "constraints", "acceptance_criteria", "caveats", "grounding"];
35
41
  /**
36
42
  * Fields deliberately excluded from the task section: identity/DAG structure
37
43
  * threaded separately through the workflow input, execution knobs, and result
@@ -41,7 +47,7 @@ export declare const STEP_TASK_FIELDS: readonly ["name", "question", "descriptio
41
47
  * {@link renderResources} section, because the budget is an execution bound,
42
48
  * not a task instruction.
43
49
  */
44
- export declare const STEP_NON_TASK_FIELDS: readonly ["id", "track", "step_type", "depends_on", "status", "resources", "agent", "timeout", "maxSteps", "summary", "artifactIds", "error", "packages"];
50
+ export declare const STEP_NON_TASK_FIELDS: readonly ["id", "track", "step_type", "depends_on", "status", "resources", "agent", "timeout", "maxSteps", "summary", "artifactIds", "error", "packages", "capability"];
45
51
  /**
46
52
  * The step's own instructions. Only `question` is guaranteed present; the
47
53
  * remaining task fields are appended as dedicated sections when populated, so a
@@ -50,7 +56,7 @@ export declare const STEP_NON_TASK_FIELDS: readonly ["id", "track", "step_type",
50
56
  * folded in here — otherwise the agent runs against the bare question and
51
57
  * improvises the rest.
52
58
  */
53
- export declare function renderTask(step: AnalysisStep): string;
59
+ export declare function renderTask(step: AnalysisStep, contract?: Pick<StepBriefing, "template" | "templateNotRetrieved">): string;
54
60
  /**
55
61
  * The heading of the analysis-memory section. The orient core of the sandbox
56
62
  * prompt names this same text, thus a test pins the two together.
@@ -75,6 +81,46 @@ export declare function emptyStepMemory(): StepMemory;
75
81
  * entry — it has no memory tool.
76
82
  */
77
83
  export declare function renderMemory(memory: StepMemory): string;
84
+ /** A file the script of the template reads. `path` names the slot that gives it, for example `{{counts_path}}`. */
85
+ export interface TemplateBriefInput {
86
+ readonly name: string;
87
+ readonly path: string;
88
+ readonly description?: string;
89
+ }
90
+ /** One plan setting bound to an adaptable slot of the template. */
91
+ export interface TemplateBoundSetting {
92
+ readonly name: string;
93
+ readonly value: TemplateBindingValue;
94
+ /** The source of the value (a doi or a document), or `plan` when the procedure names none. */
95
+ readonly source: string;
96
+ }
97
+ /**
98
+ * The contract of the template a step renders, projected for the seed. The
99
+ * parent fetches it at dispatch and binds the plan settings to it; the
100
+ * section is what lets an agent that has never seen the template make one
101
+ * valid render call.
102
+ */
103
+ export interface TemplateBrief {
104
+ /** The template reference of the plan step, with its version when the plan names one. */
105
+ readonly ref: string;
106
+ /** The version the service serves. Differs from the version of `ref` when the snapshot moved on. */
107
+ readonly version_served: string;
108
+ /** The adaptable slots of the contract, in the order the template declares them. A pinned slot is never rendered. */
109
+ readonly slots: readonly TemplateParameter[];
110
+ readonly inputs: readonly TemplateBriefInput[];
111
+ /** The plan settings bound to a slot, in plan order. Empty when the served version differs from the plan. */
112
+ readonly bound: readonly TemplateBoundSetting[];
113
+ /** The plan settings no bound slot carries, one line each with the reason. */
114
+ readonly unbound_settings: readonly string[];
115
+ }
116
+ /**
117
+ * The Template contract section. Only the adaptable slots render, bounded by
118
+ * {@link MAX_TEMPLATE_SLOTS} and the description clamp, so a wide template
119
+ * cannot blow the seed. A served version that differs from the plan renders
120
+ * as a caveat: the render refuses the plan version, and the settings are not
121
+ * bound to a version the plan did not name.
122
+ */
123
+ export declare function renderTemplateContract(brief: TemplateBrief | undefined): string;
78
124
  /**
79
125
  * What anchors every path the agent writes or reads: the id the tree is mounted
80
126
  * at, and the step's writable directory.
@@ -136,6 +182,14 @@ export interface StepBriefing {
136
182
  readonly upstream: readonly UpstreamHandoff[];
137
183
  /** The goal and the constraints of the analysis, read from the working memory at dispatch. */
138
184
  readonly memory: StepMemory;
185
+ /** The contract of the template the step renders, with the plan settings bound to it. Absent when none was retrieved. */
186
+ readonly template?: TemplateBrief;
187
+ /**
188
+ * Why the seed carries no template contract for a step whose grounding
189
+ * names one: no client is bound, the service did not answer, or the
190
+ * service does not hold the template. Read only when `template` is absent.
191
+ */
192
+ readonly templateNotRetrieved?: string;
139
193
  }
140
194
  /**
141
195
  * Compose the seed. Absent sections collapse out entirely — an independent step
@@ -30,6 +30,10 @@ export const MAX_UPSTREAM_DEPS = 5;
30
30
  export const UPSTREAM_SUMMARY_MAX_CHARS = 500;
31
31
  /** Artifact paths listed per dependency; the rest are reachable from its output directory. */
32
32
  export const MAX_UPSTREAM_ARTIFACTS = 8;
33
+ /** Adaptable slots rendered in the Template contract section; the tail is counted, not rendered. */
34
+ export const MAX_TEMPLATE_SLOTS = 24;
35
+ /** Characters of one slot description carried in the seed — the constraint, not the manual. */
36
+ export const TEMPLATE_SLOT_DESCRIPTION_MAX_CHARS = 160;
33
37
  // ── Field partition (the coverage guard's two halves) ─────────────────
34
38
  /** Fields whose content is agent-facing and MUST appear in the rendered task. */
35
39
  export const STEP_TASK_FIELDS = [
@@ -40,6 +44,9 @@ export const STEP_TASK_FIELDS = [
40
44
  "constraints",
41
45
  "acceptance_criteria",
42
46
  "caveats",
47
+ // The step agent reads the template id and the claim identifiers from the
48
+ // step, not from prose: the grounding renders beside the task fields.
49
+ "grounding",
43
50
  ];
44
51
  /**
45
52
  * Fields deliberately excluded from the task section: identity/DAG structure
@@ -70,6 +77,11 @@ export const STEP_NON_TASK_FIELDS = [
70
77
  // the agent does need to know about them — a dropped entry, or a step that
71
78
  // declares none — rides in `caveats`.
72
79
  "packages",
80
+ // Withheld for the same reason as `packages`, one layer up: the phrase is how the router and
81
+ // the planner name what they routed on, and the launch has already resolved the reference data
82
+ // it declares. The step agent learns the work from its skill pack, which names each dataset by
83
+ // what it is. Handing it a routing phrase would invite it to treat that phrase as the method.
84
+ "capability",
73
85
  ];
74
86
  // ── Formatting primitives ─────────────────────────────────────────────
75
87
  function section(heading, body) {
@@ -95,7 +107,7 @@ function clamp(text, max) {
95
107
  * folded in here — otherwise the agent runs against the bare question and
96
108
  * improvises the rest.
97
109
  */
98
- export function renderTask(step) {
110
+ export function renderTask(step, contract = {}) {
99
111
  const parts = [`# ${step.name}`, section("Task", step.question)];
100
112
  if (step.description?.trim()) {
101
113
  parts.push(section("What this step produces", step.description.trim()));
@@ -112,8 +124,46 @@ export function renderTask(step) {
112
124
  if (step.caveats && step.caveats.length > 0) {
113
125
  parts.push(section("Caveats", bullets(step.caveats)));
114
126
  }
127
+ if (step.grounding) {
128
+ parts.push(section("Grounding", renderGrounding(step.grounding, contract)));
129
+ }
115
130
  return parts.join("\n\n");
116
131
  }
132
+ /**
133
+ * The grounding as data lines. The template id is the one value the agent
134
+ * acts on (it names it to `knowledge_template`), thus it renders first after
135
+ * the status. The claim identifiers and the digest are for the record.
136
+ *
137
+ * A grounding that names a template promises a Template contract section.
138
+ * When the seed carries none, one line says so with the reason, thus the
139
+ * agent knows the contract was not fetched rather than guessing at slots.
140
+ *
141
+ * The settings of the procedure ride here as vetted values, not as
142
+ * constraints: the agent uses each one unless the data gives a reason for
143
+ * another value, and then it states the reason. They render only when the
144
+ * seed carries no template contract, because the bound and the unbound
145
+ * lists of the contract hold every setting already.
146
+ */
147
+ function renderGrounding(grounding, contract) {
148
+ const lines = [`- Status: ${grounding.status}`];
149
+ if (grounding.template) {
150
+ lines.push(`- Template: \`${grounding.template}\``);
151
+ if (!contract.template)
152
+ lines.push(`- Template contract: not retrieved (${contract.templateNotRetrieved ?? "no contract was fetched for this step"})`);
153
+ }
154
+ lines.push(`- Snapshot: ${grounding.snapshot}`);
155
+ lines.push(`- Claims: ${grounding.claims.length > 0 ? grounding.claims.join(", ") : "none"}`);
156
+ lines.push(`- Reason: ${grounding.reason}`);
157
+ const settings = grounding.settings ?? [];
158
+ if (settings.length > 0 && !contract.template) {
159
+ lines.push("- Settings (the vetted values of the procedure; another value is permitted when your summary states the reason):");
160
+ for (const setting of settings) {
161
+ const value = Array.isArray(setting.value) ? setting.value.join(", ") : String(setting.value);
162
+ lines.push(` - ${setting.step}: ${setting.name} = ${value}${setting.source ? ` (${setting.source})` : ""}`);
163
+ }
164
+ }
165
+ return lines.join("\n");
166
+ }
117
167
  // ── (1b) Analysis memory ──────────────────────────────────────────────
118
168
  /**
119
169
  * The heading of the analysis-memory section. The orient core of the sandbox
@@ -155,6 +205,82 @@ export function renderMemory(memory) {
155
205
  }
156
206
  return section(ANALYSIS_MEMORY_HEADING, parts.join("\n\n"));
157
207
  }
208
+ /** A value as the render request carries it: JSON, thus `"apeglm"` and `10` cannot be confused. */
209
+ function slotValue(value) {
210
+ return JSON.stringify(value);
211
+ }
212
+ /** The version of a template reference (`tpl-x@1.0.0` gives `1.0.0`), or `undefined` when the reference has none. */
213
+ function versionOfRef(ref) {
214
+ const at = ref.indexOf("@");
215
+ return at < 0 ? undefined : ref.slice(at + 1);
216
+ }
217
+ /**
218
+ * One adaptable slot as a bullet: the name, the type, the default with its
219
+ * source or the required state, the description, and the constraints the
220
+ * render enforces. A slot without a default is required unless the contract
221
+ * says otherwise, which is the rule of the renderer.
222
+ */
223
+ function renderSlot(slot) {
224
+ const facts = [slot.type];
225
+ // A local slot is bound on this machine: the agent sends it as any other slot, and the value never leaves.
226
+ if (slot.local)
227
+ facts.push("local");
228
+ if (slot.default !== undefined) {
229
+ facts.push(`default ${slotValue(slot.default)}${slot.default_source ? ` [${slot.default_source}]` : ""}`);
230
+ }
231
+ else {
232
+ facts.push(slot.required === false ? "optional" : "required");
233
+ }
234
+ if (slot.minimum !== undefined)
235
+ facts.push(`min ${slot.minimum}`);
236
+ if (slot.maximum !== undefined)
237
+ facts.push(`max ${slot.maximum}`);
238
+ const tail = [];
239
+ if (slot.enum && slot.enum.length > 0)
240
+ tail.push(`Permitted: ${slot.enum.map(slotValue).join(", ")}.`);
241
+ if (slot.pattern)
242
+ tail.push(`Pattern: \`${slot.pattern}\`.`);
243
+ const description = clamp(slot.description.trim(), TEMPLATE_SLOT_DESCRIPTION_MAX_CHARS);
244
+ return `\`${slot.name}\` (${facts.join("; ")})${description ? `: ${description}` : ""}${tail.length > 0 ? ` ${tail.join(" ")}` : ""}`;
245
+ }
246
+ /**
247
+ * The Template contract section. Only the adaptable slots render, bounded by
248
+ * {@link MAX_TEMPLATE_SLOTS} and the description clamp, so a wide template
249
+ * cannot blow the seed. A served version that differs from the plan renders
250
+ * as a caveat: the render refuses the plan version, and the settings are not
251
+ * bound to a version the plan did not name.
252
+ */
253
+ export function renderTemplateContract(brief) {
254
+ if (!brief)
255
+ return "";
256
+ const refVersion = versionOfRef(brief.ref);
257
+ const lead = [
258
+ `\`${brief.ref}\` (the service serves version ${brief.version_served}). Send only the slots listed here to \`knowledge_template\`; a slot not listed is pinned or unknown, and the render refuses it.`,
259
+ ];
260
+ if (refVersion !== undefined && refVersion !== brief.version_served) {
261
+ lead.push(`Caveat: the plan names version ${refVersion}, and the service serves version ${brief.version_served}. The plan settings are not bound to this render. Render the served version, and state each plan value yourself.`);
262
+ }
263
+ const blocks = [lead.join("\n")];
264
+ const adaptable = brief.slots.filter((slot) => slot.adaptable);
265
+ if (adaptable.length > 0) {
266
+ const shown = adaptable.slice(0, MAX_TEMPLATE_SLOTS);
267
+ const lines = [bullets(shown.map(renderSlot))];
268
+ const omitted = adaptable.length - shown.length;
269
+ if (omitted > 0)
270
+ lines.push(`(+${omitted} more adaptable ${omitted === 1 ? "slot" : "slots"} not listed. The render answer names a required one that is absent.)`);
271
+ blocks.push(`Slots:\n${lines.join("\n")}`);
272
+ }
273
+ if (brief.inputs.length > 0) {
274
+ blocks.push(`Inputs the script reads:\n${bullets(brief.inputs.map((input) => `${input.name}: \`${input.path}\`${input.description ? ` — ${input.description}` : ""}`))}`);
275
+ }
276
+ if (brief.bound.length > 0) {
277
+ blocks.push(`Bound by the plan (send each value as it is, or add an \`overrides\` entry with the slot and the reason for the change; a changed value without an override is refused):\n${bullets(brief.bound.map((setting) => `\`${setting.name}\` = ${slotValue(setting.value)} (${setting.source})`))}`);
278
+ }
279
+ if (brief.unbound_settings.length > 0) {
280
+ blocks.push(`Unbound settings (the plan states them, and no bound slot carries them):\n${bullets(brief.unbound_settings)}`);
281
+ }
282
+ return section("Template contract", blocks.join("\n\n"));
283
+ }
158
284
  /** The in-sandbox analysis root of a frame — the read-only mount of the whole tree. */
159
285
  export function analysisRootOf(frame) {
160
286
  return `/${frame.analysisId}`;
@@ -240,7 +366,8 @@ function renderHandoff(handoff) {
240
366
  */
241
367
  export function composeStepBriefing(briefing) {
242
368
  return [
243
- renderTask(briefing.step),
369
+ renderTask(briefing.step, briefing),
370
+ renderTemplateContract(briefing.template),
244
371
  renderMemory(briefing.memory),
245
372
  renderWorkspace(briefing.workspace),
246
373
  renderResources(briefing.step.resources),