@inflexa-ai/harness 0.48.0 → 0.48.2

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 (115) hide show
  1. package/dist/contracts/chat-events.d.ts +40 -0
  2. package/dist/contracts/chat-frame.js +11 -0
  3. package/dist/contracts/index.d.ts +1 -1
  4. package/dist/contracts/message.d.ts +7 -1
  5. package/dist/contracts/schemas/chat-events.d.ts +319 -0
  6. package/dist/contracts/schemas/chat-events.js +29 -0
  7. package/dist/execution/run-synthesis.js +1 -0
  8. package/dist/index.d.ts +5 -3
  9. package/dist/index.js +2 -1
  10. package/dist/input-sanitization.d.ts +30 -2
  11. package/dist/input-sanitization.js +187 -2
  12. package/dist/loop/run-loop.js +50 -5
  13. package/dist/loop/tool-adapter.d.ts +8 -0
  14. package/dist/loop/tool-adapter.js +75 -4
  15. package/dist/loop/tool-detail.d.ts +5 -1
  16. package/dist/loop/tool-detail.js +7 -3
  17. package/dist/loop/tool-value.d.ts +57 -0
  18. package/dist/loop/tool-value.js +249 -0
  19. package/dist/loop/types.d.ts +9 -2
  20. package/dist/memory/conversation-display-storage.js +4 -1
  21. package/dist/memory/count-tokens.js +29 -2
  22. package/dist/runtime/dbos.d.ts +6 -0
  23. package/dist/runtime/dbos.js +14 -2
  24. package/dist/sandbox/sandbox-step-translate.d.ts +5 -9
  25. package/dist/sandbox/sandbox-step-translate.js +5 -9
  26. package/dist/state/artifacts.d.ts +4 -1
  27. package/dist/state/artifacts.js +4 -1
  28. package/dist/state/index.d.ts +2 -2
  29. package/dist/state/index.js +1 -1
  30. package/dist/state/plans.d.ts +27 -0
  31. package/dist/state/plans.js +57 -3
  32. package/dist/tasks/data-profile-activity.d.ts +3 -7
  33. package/dist/tasks/data-profile-activity.js +1 -1
  34. package/dist/tasks/data-profile.js +3 -6
  35. package/dist/tools/ad-hoc-router.js +1 -0
  36. package/dist/tools/bio/alphafold-prediction.js +1 -0
  37. package/dist/tools/bio/chembl.js +1 -0
  38. package/dist/tools/bio/comptox.js +1 -0
  39. package/dist/tools/bio/drug-gene-interactions.js +1 -0
  40. package/dist/tools/bio/gene-disease-evidence.js +1 -0
  41. package/dist/tools/bio/gene-preclinical-profile.js +1 -0
  42. package/dist/tools/bio/lookup-annotation.js +1 -0
  43. package/dist/tools/bio/opentargets.js +1 -0
  44. package/dist/tools/bio/pubchem.js +1 -0
  45. package/dist/tools/bio/pubmed.js +1 -0
  46. package/dist/tools/bio/search-clinical-trials.js +1 -0
  47. package/dist/tools/bio/search-faers.js +1 -0
  48. package/dist/tools/bio/search-gene.js +1 -0
  49. package/dist/tools/bio/search-geo-datasets.js +1 -0
  50. package/dist/tools/bio/search-interactions.js +1 -0
  51. package/dist/tools/bio/search-patents.js +1 -0
  52. package/dist/tools/bio/search-protein.js +1 -0
  53. package/dist/tools/bio/search-regulatory-approvals.js +1 -0
  54. package/dist/tools/bio/target-safety.js +1 -0
  55. package/dist/tools/capability-data.js +5 -0
  56. package/dist/tools/define-tool.d.ts +18 -0
  57. package/dist/tools/define-tool.js +2 -0
  58. package/dist/tools/display/show-user.js +1 -0
  59. package/dist/tools/execute-analysis.js +4 -0
  60. package/dist/tools/knowledge/check.d.ts +47 -1
  61. package/dist/tools/knowledge/check.js +10 -1
  62. package/dist/tools/knowledge/client.d.ts +1 -1
  63. package/dist/tools/knowledge/recommend.js +4 -3
  64. package/dist/tools/knowledge/situation.d.ts +39 -1
  65. package/dist/tools/knowledge/situation.js +80 -8
  66. package/dist/tools/knowledge/skeleton.d.ts +7 -0
  67. package/dist/tools/knowledge/skeleton.js +88 -2
  68. package/dist/tools/knowledge/template.js +1 -0
  69. package/dist/tools/memory/update-working-memory.js +5 -0
  70. package/dist/tools/read-tool-output.js +4 -0
  71. package/dist/tools/report-authoring/authoring-tools.js +8 -0
  72. package/dist/tools/report-session/examine-page.js +5 -0
  73. package/dist/tools/report-session/list-artifacts.js +5 -0
  74. package/dist/tools/report-session/preview-report.js +1 -0
  75. package/dist/tools/report-session/record-version.js +1 -0
  76. package/dist/tools/report-session/script-tools.js +2 -0
  77. package/dist/tools/research/context7-docs.js +2 -0
  78. package/dist/tools/research/generate-analogy-report.js +3 -0
  79. package/dist/tools/research/generate-plan.js +6 -0
  80. package/dist/tools/research/inspect-data-profile.js +5 -0
  81. package/dist/tools/research/inspect-run.js +5 -0
  82. package/dist/tools/research/resolve-citation.js +1 -0
  83. package/dist/tools/research/search-arxiv.js +1 -0
  84. package/dist/tools/research/search-github-repos.js +1 -0
  85. package/dist/tools/research/search-semantic-scholar.js +1 -0
  86. package/dist/tools/research/web-fetch.js +1 -0
  87. package/dist/tools/research/web-search.js +1 -0
  88. package/dist/tools/sandbox/lease-tools.js +2 -0
  89. package/dist/tools/sandbox/link-packages.js +1 -0
  90. package/dist/tools/sandbox/list-available-packages.js +5 -0
  91. package/dist/tools/sandbox/list-available-refs.js +5 -0
  92. package/dist/tools/sandbox/report-blocker.js +1 -0
  93. package/dist/tools/sandbox/skills.js +2 -0
  94. package/dist/tools/sandbox/submit-file-metadata.js +1 -0
  95. package/dist/tools/script/file-tools.js +6 -0
  96. package/dist/tools/script/run-script.js +20 -1
  97. package/dist/tools/start-report-session.js +1 -0
  98. package/dist/tools/tool-view.d.ts +63 -0
  99. package/dist/tools/tool-view.js +68 -0
  100. package/dist/tools/workspace/edit-file.js +5 -0
  101. package/dist/tools/workspace/execute-command.js +5 -2
  102. package/dist/tools/workspace/file-stat.js +5 -0
  103. package/dist/tools/workspace/grep.js +4 -0
  104. package/dist/tools/workspace/list-files.js +5 -0
  105. package/dist/tools/workspace/read-file.js +4 -0
  106. package/dist/tools/workspace/scan-inputs.js +5 -0
  107. package/dist/tools/workspace/show-file.js +1 -0
  108. package/dist/tools/workspace/show-plan.js +1 -0
  109. package/dist/tools/workspace/workspace-search.js +5 -0
  110. package/dist/tools/workspace/write-file.js +7 -2
  111. package/dist/workflows/execute-analysis.js +2 -1
  112. package/dist/workflows/sandbox-step.js +3 -7
  113. package/package.json +1 -1
  114. package/dist/tools/detail-resolver.d.ts +0 -40
  115. package/dist/tools/detail-resolver.js +0 -44
@@ -0,0 +1,249 @@
1
+ /**
2
+ * The shown values of a tool call: its input, its result, and its error.
3
+ *
4
+ * The emit site makes each value one time, and a host only shows it. A tool declares what it shows through its view
5
+ * (`tools/tool-view.ts`), and {@link makeToolCallValue} turns the output of a view member into a value with a kind.
6
+ * Each leaf of a value gets the steps below on its own. The steps run in this order, because each step
7
+ * protects the next one: the control characters go first, thus a control byte inside a secret cannot stop a pattern.
8
+ * The secrets go before the cut, thus a cut cannot divide a secret and let one half pass. The shown text can differ
9
+ * from the text that the model reads.
10
+ */
11
+ import { err, ok } from "neverthrow";
12
+ import { z } from "zod";
13
+ import { TOOL_CALL_FIELDS_MAX, ToolCallValueSchema } from "../contracts/schemas/chat-events.js";
14
+ import { isUnknownArray } from "../lib/unknown-array.js";
15
+ import { parseJsonAs } from "../lib/parse-json.js";
16
+ import { describeZodIssueShapes } from "../lib/zod-issue-shape.js";
17
+ import { parseUnknownRecord } from "../lib/unknown-containers.js";
18
+ import { normalizeUnicode, redactSecretFields, redactSecretForms, redactSecrets } from "../input-sanitization.js";
19
+ import { ViewCode, ViewFields, ViewJson, ViewText } from "../tools/tool-view.js";
20
+ import { pairSafeIndex } from "./tool-output.js";
21
+ /** A text longer than this is cut. The unit is the UTF-16 code unit. */
22
+ export const TOOL_VALUE_CAP = 8 * 1024;
23
+ /** The start of a cut text holds the shape of a value. */
24
+ export const TOOL_VALUE_HEAD_CHARS = 3 * 1024;
25
+ /** The end of a cut text holds the errors of a command, thus it gets more room than the start. */
26
+ export const TOOL_VALUE_TAIL_CHARS = 5 * 1024;
27
+ /**
28
+ * The text past each shown part that the rules also read. A secret at the edge of a shown part is whole in its window
29
+ * when it is shorter than this margin, thus the rules see it as they see it in the whole text.
30
+ */
31
+ export const TOOL_VALUE_CLEAN_MARGIN = 8 * 1024;
32
+ const HEAD_WINDOW = TOOL_VALUE_HEAD_CHARS + TOOL_VALUE_CLEAN_MARGIN;
33
+ const TAIL_WINDOW = TOOL_VALUE_TAIL_CHARS + TOOL_VALUE_CLEAN_MARGIN;
34
+ /**
35
+ * A text up to this length gets the rules whole. A longer text gets them on its two windows only, thus the cost of a
36
+ * value does not grow with the size of a result.
37
+ */
38
+ const WHOLE_CLEAN_MAX = HEAD_WINDOW + TAIL_WINDOW;
39
+ function cleanText(text) {
40
+ return redactSecrets(redactSecretForms(normalizeUnicode(text)));
41
+ }
42
+ function cleanStrings(value) {
43
+ if (typeof value === "string")
44
+ return cleanText(value);
45
+ if (isUnknownArray(value))
46
+ return value.map(cleanStrings);
47
+ const record = parseUnknownRecord(value);
48
+ if (record === undefined)
49
+ return value;
50
+ const out = {};
51
+ for (const [name, field] of Object.entries(record))
52
+ out[cleanText(name)] = cleanStrings(field);
53
+ return out;
54
+ }
55
+ /** The shown start of `head` and the shown end of `tail`, with a marker that counts the code units of `length` not shown. */
56
+ function cutParts(head, tail, length) {
57
+ const shownHead = head.slice(0, pairSafeIndex(head, TOOL_VALUE_HEAD_CHARS, -1));
58
+ const shownTail = tail.slice(pairSafeIndex(tail, Math.max(0, tail.length - TOOL_VALUE_TAIL_CHARS), 1));
59
+ const marker = `[... ${Math.max(0, length - shownHead.length - shownTail.length)} characters not shown ...]`;
60
+ return { text: `${shownHead}\n${marker}\n${shownTail}`, cut: true };
61
+ }
62
+ function cutText(clean) {
63
+ return clean.length <= TOOL_VALUE_CAP ? { text: clean, cut: false } : cutParts(clean, clean, clean.length);
64
+ }
65
+ /** The shown text of a plain text. A long text gets the rules on its two windows, and its marker counts the original text. */
66
+ function shownText(text) {
67
+ if (text.length <= WHOLE_CLEAN_MAX)
68
+ return cutText(cleanText(text));
69
+ const head = cleanText(text.slice(0, pairSafeIndex(text, HEAD_WINDOW, -1)));
70
+ const tail = cleanText(text.slice(pairSafeIndex(text, text.length - TAIL_WINDOW, 1)));
71
+ return cutParts(head, tail, text.length);
72
+ }
73
+ /** The JSON text of a value, or `undefined` for a function, a symbol, and `undefined`, which the lib type omits. */
74
+ function jsonText(value) {
75
+ return JSON.stringify(value);
76
+ }
77
+ function serializeClean(value) {
78
+ return jsonText(cleanStrings(redactSecretFields(value)) ?? null) ?? "null";
79
+ }
80
+ /** The fields of a plain record whose JSON text is its own fields, or `undefined` for each other value. */
81
+ function plainFields(value) {
82
+ if (value === null || value === undefined)
83
+ return undefined;
84
+ const prototype = Object.getPrototypeOf(value);
85
+ if (prototype !== Object.prototype && prototype !== null)
86
+ return undefined;
87
+ const fields = Object.entries(value);
88
+ return fields.some(([name, field]) => name === "toJSON" && typeof field === "function") ? undefined : fields;
89
+ }
90
+ /**
91
+ * A copy of one end of `value`: the JSON text of the copy starts (`head`) or ends (`tail`) with at least `budget.left`
92
+ * code units of the JSON text of `value`. The count of a string ignores its escapes, and an escape only makes the text
93
+ * longer, thus the copy never holds less than the budget. A value that is not an array, a plain record, or a string
94
+ * stays whole, because its JSON text comes from its own `toJSON`.
95
+ */
96
+ function endOf(value, budget, end) {
97
+ if (typeof value === "string") {
98
+ const keep = Math.max(0, budget.left);
99
+ const kept = end === "head" ? value.slice(0, keep) : value.slice(Math.max(0, value.length - keep));
100
+ budget.left -= kept.length + 2;
101
+ return kept;
102
+ }
103
+ if (isUnknownArray(value)) {
104
+ budget.left -= 2;
105
+ const kept = [];
106
+ for (let index = 0; index < value.length && budget.left > 0; index += 1) {
107
+ kept.push(endOf(value[end === "head" ? index : value.length - 1 - index], budget, end));
108
+ budget.left -= 1;
109
+ }
110
+ return end === "head" ? kept : kept.reverse();
111
+ }
112
+ const fields = plainFields(value);
113
+ if (fields !== undefined) {
114
+ budget.left -= 2;
115
+ const kept = [];
116
+ for (let index = 0; index < fields.length && budget.left > 0; index += 1) {
117
+ const entry = fields[end === "head" ? index : fields.length - 1 - index];
118
+ // JSON text omits such a field, and a test of the field by its own serialization would read all of it.
119
+ if (entry === undefined || entry[1] === undefined || typeof entry[1] === "function" || typeof entry[1] === "symbol")
120
+ continue;
121
+ budget.left -= entry[0].length + 4;
122
+ kept.push([entry[0], endOf(entry[1], budget, end)]);
123
+ }
124
+ return Object.fromEntries(end === "head" ? kept : kept.reverse());
125
+ }
126
+ budget.left -= jsonText(value)?.length ?? 0;
127
+ return value;
128
+ }
129
+ /**
130
+ * The shown JSON text of a value: `ok(undefined)` when the value has no JSON text, and an error with the reason when
131
+ * the value does not serialize, for example a cycle.
132
+ */
133
+ function shownJson(value) {
134
+ try {
135
+ const raw = jsonText(value);
136
+ if (raw === undefined)
137
+ return ok(undefined);
138
+ if (raw.length <= WHOLE_CLEAN_MAX)
139
+ return ok(cutText(serializeClean(value)));
140
+ const head = serializeClean(endOf(value, { left: HEAD_WINDOW }, "head"));
141
+ const tail = serializeClean(endOf(value, { left: TAIL_WINDOW }, "tail"));
142
+ return ok(cutParts(head, tail, raw.length));
143
+ }
144
+ catch {
145
+ return err("the value does not serialize to JSON");
146
+ }
147
+ }
148
+ function jsonLeaf(value) {
149
+ return shownJson(value).map((shown) => (shown === undefined ? undefined : { kind: "json", ...shown }));
150
+ }
151
+ /** The leaf of a plain text: control characters out, each secret out, then the cut. */
152
+ export function textToolValue(text) {
153
+ return { kind: "text", ...shownText(text) };
154
+ }
155
+ /** The leaf of source code. The rules and the cut are those of {@link textToolValue}. `language` is checked with the whole value. */
156
+ export function codeToolValue(language, text) {
157
+ return { kind: "code", language, ...shownText(text) };
158
+ }
159
+ /**
160
+ * The leaf of a JSON value. A string shows as a JSON string, and each other value shows as its JSON text.
161
+ * The field-name rule runs first, then the text rules on each string, then the serialization, then the cut: a text
162
+ * rule never meets a JSON escape, which would hide the end of a quoted secret.
163
+ * A long JSON text gets the rules on a copy of each end of the value only, and its marker counts the JSON text of the
164
+ * original value. Only the serialization of the whole value grows with its size, and the model path pays it already.
165
+ * A value with no JSON text, and a value that does not serialize, for example a cycle, give no leaf: a value is a
166
+ * diagnostic and never fails a call.
167
+ */
168
+ export function jsonToolValue(value) {
169
+ return jsonLeaf(value).unwrapOr(undefined);
170
+ }
171
+ const AdapterErrorSchema = z.object({ error: z.string() });
172
+ /**
173
+ * The leaf of an error text. The adapter writes the error of a tool as the JSON text `{ "error": message, ... }`, and
174
+ * the leaf shows the message. Each other error text, for example a refusal of the call policy, shows whole.
175
+ */
176
+ export function errorToolValue(text) {
177
+ try {
178
+ return textToolValue(parseJsonAs(text, AdapterErrorSchema).error);
179
+ }
180
+ catch {
181
+ return textToolValue(text);
182
+ }
183
+ }
184
+ function leafValue(leaf) {
185
+ if (leaf instanceof ViewText)
186
+ return ok(textToolValue(leaf.text));
187
+ if (leaf instanceof ViewCode)
188
+ return ok(codeToolValue(leaf.language, leaf.text));
189
+ return jsonLeaf(leaf.value);
190
+ }
191
+ function fieldsValue(view) {
192
+ if (view.fields.length > TOOL_CALL_FIELDS_MAX)
193
+ return err(`a fields value holds more than ${TOOL_CALL_FIELDS_MAX} fields`);
194
+ const fields = [];
195
+ for (const { name, leaf } of view.fields) {
196
+ // A leaf that is not a leaf class is a nested `fields` value, which the kinds refuse.
197
+ if (!(leaf instanceof ViewText || leaf instanceof ViewCode || leaf instanceof ViewJson))
198
+ return err("a fields value holds a fields value");
199
+ const value = leafValue(leaf);
200
+ if (value.isErr())
201
+ return err(`the field ${name.slice(0, 40)}: ${value.error}`);
202
+ if (value.value !== undefined)
203
+ fields.push({ name, ...value.value });
204
+ }
205
+ return ok(fields.length === 0 ? undefined : { kind: "fields", fields });
206
+ }
207
+ function leafOfOutput(output) {
208
+ if (typeof output === "string")
209
+ return ok(textToolValue(output));
210
+ if (output instanceof ViewText || output instanceof ViewCode || output instanceof ViewJson)
211
+ return leafValue(output);
212
+ return jsonLeaf(output).andThen((leaf) => (leaf === undefined ? err("the value has no JSON text") : ok(leaf)));
213
+ }
214
+ /**
215
+ * The value of the output of a view member: a helper object gives its kind, a string gives a `text` leaf, and each other
216
+ * value gives a `json` leaf. Each leaf gets the cleaning and the cap of its kind. The result holds no value for an output
217
+ * that is absent (silent), and for a `fields` value with no field. It holds the reason for an output that cannot show: a
218
+ * value with no JSON text, a value that does not serialize, and a value that breaks a rule of the kinds (more fields than
219
+ * the limit, a nested `fields` value, a bad field name, a bad language). The caller logs the reason. It never throws.
220
+ */
221
+ export function makeToolCallValue(output) {
222
+ if (output === undefined)
223
+ return ok(undefined);
224
+ const made = output instanceof ViewFields ? fieldsValue(output) : leafOfOutput(output);
225
+ return made.andThen((value) => {
226
+ if (value === undefined)
227
+ return ok(undefined);
228
+ const checked = ToolCallValueSchema.safeParse(value);
229
+ return checked.success ? ok(checked.data) : err(describeZodIssueShapes(checked.error, ToolCallValueSchema).join("; "));
230
+ });
231
+ }
232
+ /**
233
+ * The event without the three values. A forwarder of loop events to the stream of a run calls it, because no reader
234
+ * of that stream shows a value, and a value costs bytes on each event.
235
+ */
236
+ export function withoutToolValues(event) {
237
+ switch (event.type) {
238
+ case "tool-started": {
239
+ const { input: _input, ...rest } = event;
240
+ return rest;
241
+ }
242
+ case "tool-finished": {
243
+ const { input: _input, result: _result, error: _error, ...rest } = event;
244
+ return rest;
245
+ }
246
+ default:
247
+ return event;
248
+ }
249
+ }
@@ -3,7 +3,7 @@
3
3
  * messages, and the flat event sink.
4
4
  */
5
5
  import type { ModelMessage } from "ai";
6
- import type { ToolCallDetail, ToolOutcome } from "../contracts/chat-events.js";
6
+ import type { ToolCallDetail, ToolCallValue, ToolOutcome } from "../contracts/chat-events.js";
7
7
  import type { ChatStreamEvent, ProviderOptions } from "../providers/types.js";
8
8
  import type { Tool } from "../tools/define-tool.js";
9
9
  import type { CompactionRules } from "./compaction.js";
@@ -75,13 +75,14 @@ export type EmitEvent = {
75
75
  readonly source: EventSource;
76
76
  readonly toolUseId: string;
77
77
  readonly name: string;
78
- readonly input: unknown;
79
78
  /**
80
79
  * One line naming what this call is doing, from the tool's own
81
80
  * `describeCall` hook. Absent — never empty — when the tool declares no
82
81
  * hook or the detail could not be produced (see `tool-detail.ts`).
83
82
  */
84
83
  readonly detail?: ToolCallDetail;
84
+ /** The input that the view of the tool declares, with each listed secret form removed and a cap. Absent for a call that is not a root call. */
85
+ readonly input?: ToolCallValue;
85
86
  } | {
86
87
  readonly type: "tool-finished";
87
88
  readonly source: EventSource;
@@ -101,6 +102,12 @@ export type EmitEvent = {
101
102
  * can fall back to its own observation.
102
103
  */
103
104
  readonly durationMs?: number;
105
+ /** The input again: a replay of a tool step does not give its `tool-started`. */
106
+ readonly input?: ToolCallValue;
107
+ /** The result of a call with the outcome `ok`. */
108
+ readonly result?: ToolCallValue;
109
+ /** The error text of a call with the outcome `error`. */
110
+ readonly error?: ToolCallValue;
104
111
  } | {
105
112
  /** The display recorder of a chat turn emits it, not the loop. See `MessageStartEvent`. */
106
113
  readonly type: "message-start";
@@ -20,7 +20,7 @@ import { isDataUIPart, validateUIMessages } from "ai";
20
20
  import { z } from "zod";
21
21
  import { parseUnknownRecord } from "../lib/unknown-containers.js";
22
22
  import { AskPartSchema, ChildSessionStartedPartSchema, CompactionPartSchema, FileReferencePartSchema, PlanApprovalPartSchema, PlanPartSchema, PresentationPartSchema, ReportRenderedPartSchema, RunCardPartSchema, SubAgentActivityEntrySchema, SubAgentActivityPartSchema, } from "../contracts/schemas/chat-parts.js";
23
- import { ToolCallOutcomeSchema } from "../contracts/schemas/chat-events.js";
23
+ import { ToolCallOutcomeSchema, ToolCallValueSchema } from "../contracts/schemas/chat-events.js";
24
24
  export const SUPPORTED_DISPLAY_SCHEMA_VERSION = 1;
25
25
  const DisplayEnvelopeHeaderSchema = z.object({
26
26
  kind: z.literal("ai-sdk-ui-messages"),
@@ -48,6 +48,9 @@ const ToolCallDisplaySchema = z.object({
48
48
  outcome: ToolCallOutcomeSchema,
49
49
  detail: z.string().optional(),
50
50
  durationMs: z.number().optional(),
51
+ input: ToolCallValueSchema.optional(),
52
+ result: ToolCallValueSchema.optional(),
53
+ error: ToolCallValueSchema.optional(),
51
54
  });
52
55
  /**
53
56
  * The schema behind each stored part key.
@@ -16,6 +16,33 @@ function enc() {
16
16
  encoder ??= getEncoding("cl100k_base");
17
17
  return encoder;
18
18
  }
19
+ /**
20
+ * A run of this many characters with no whitespace is counted in chunks. The BPE merge of `js-tiktoken` is
21
+ * quadratic in the length of one pre-token, and a run of one repeated character of 20,000 characters takes
22
+ * about 13 s whole. A base64 blob or a sequence with no line break is such a run in a tool result.
23
+ */
24
+ // oxlint-disable-next-line eslint-js/no-restricted-syntax -- a run of 256 or more characters with no whitespace; no builtin finds a run by its length.
25
+ const LONG_RUN = /\S{256,}/g;
26
+ /** The chunk of a long run. A chunk of 64 characters costs well under a millisecond in the worst case. */
27
+ const RUN_CHUNK = 64;
28
+ /**
29
+ * The token count of one text. A text with no long run is one encoding, thus its count is exact. Each long run
30
+ * is counted in chunks, which can add a token at each chunk edge: the count is an approximation already.
31
+ */
32
+ function encodedLength(text) {
33
+ let total = 0;
34
+ let from = 0;
35
+ for (const run of text.matchAll(LONG_RUN)) {
36
+ if (run.index > from)
37
+ total += enc().encode(text.slice(from, run.index)).length;
38
+ for (let at = 0; at < run[0].length; at += RUN_CHUNK)
39
+ total += enc().encode(run[0].slice(at, at + RUN_CHUNK)).length;
40
+ from = run.index + run[0].length;
41
+ }
42
+ if (from < text.length)
43
+ total += enc().encode(text.slice(from)).length;
44
+ return total;
45
+ }
19
46
  /**
20
47
  * The token-bearing text of one tool-result output.
21
48
  *
@@ -82,13 +109,13 @@ function tokenizableText(block) {
82
109
  */
83
110
  export function countTokens(content) {
84
111
  if (typeof content === "string") {
85
- return content.length === 0 ? 0 : enc().encode(content).length;
112
+ return content.length === 0 ? 0 : encodedLength(content);
86
113
  }
87
114
  let total = 0;
88
115
  for (const block of content) {
89
116
  const text = tokenizableText(block);
90
117
  if (text.length > 0)
91
- total += enc().encode(text).length;
118
+ total += encodedLength(text);
92
119
  }
93
120
  return total;
94
121
  }
@@ -98,6 +98,12 @@ export type WorkflowSchedule = {
98
98
  * schedule of this list. A second call with the same name replaces the first.
99
99
  */
100
100
  export declare function scheduleWorkflow(schedule: WorkflowSchedule): void;
101
+ /**
102
+ * Store each schedule of {@link scheduleWorkflow} in the system database, and delete each stored schedule of this
103
+ * application that no code registers. DBOS only upserts, thus a schedule of a removed workflow fires on each tick and
104
+ * finds no function. Each schedule of the harness goes through `scheduleWorkflow`.
105
+ */
106
+ export declare function applyWorkflowSchedules(): Promise<void>;
101
107
  /**
102
108
  * Launch DBOS. Idempotent — a second call is a no-op so tests that drive
103
109
  * the harness twice (or accidentally double-import) don't re-launch.
@@ -130,6 +130,19 @@ const pendingSchedules = new Map();
130
130
  export function scheduleWorkflow(schedule) {
131
131
  pendingSchedules.set(schedule.scheduleName, schedule);
132
132
  }
133
+ /**
134
+ * Store each schedule of {@link scheduleWorkflow} in the system database, and delete each stored schedule of this
135
+ * application that no code registers. DBOS only upserts, thus a schedule of a removed workflow fires on each tick and
136
+ * finds no function. Each schedule of the harness goes through `scheduleWorkflow`.
137
+ */
138
+ export async function applyWorkflowSchedules() {
139
+ if (pendingSchedules.size > 0)
140
+ await DBOS.applySchedules([...pendingSchedules.values()]);
141
+ for (const stored of await DBOS.listSchedules()) {
142
+ if (!pendingSchedules.has(stored.scheduleName))
143
+ await DBOS.deleteSchedule(stored.scheduleName);
144
+ }
145
+ }
133
146
  let resultRecipesRegistered = false;
134
147
  /**
135
148
  * DBOS refuses a registration after the launch, and the registry of SuperJSON
@@ -160,8 +173,7 @@ export async function launchDbos({ config, logger: injected }) {
160
173
  registerResultSerialization();
161
174
  const start = performance.now();
162
175
  await DBOS.launch();
163
- if (pendingSchedules.size > 0)
164
- await DBOS.applySchedules([...pendingSchedules.values()]);
176
+ await applyWorkflowSchedules();
165
177
  state.launched = true;
166
178
  state.recoveryStarted = true;
167
179
  logger.info("launched", {
@@ -7,7 +7,6 @@
7
7
  */
8
8
  import type { ActivityEntryPart, StepActivityPart, StepPhase, SynthesisPhase, SynthesisProgressPart } from "../contracts/chat-parts.js";
9
9
  import type { ChatDataPart, EmitFn } from "../loop/types.js";
10
- import type { ToolDetailResolver } from "../tools/detail-resolver.js";
11
10
  /**
12
11
  * Stable reconciliation id for a step's reconciling parts
13
12
  * (`data-step-activity`, `data-step-file-tree`). One id per (runId, stepId)
@@ -49,11 +48,9 @@ export declare function synthesisEmission(runId: string, stepId: string, seq: nu
49
48
  * The live-activity phrase for one tool call: the tool's name, then its own
50
49
  * description of this call when it has one.
51
50
  *
52
- * The description comes from the tool's `describeCall` hook, resolved through
53
- * `resolveDetail` — the same resolver the chat chip and the reloaded transcript
54
- * use. A caller supplies the resolver for the agent whose calls it is
55
- * describing, because a sandbox agent's tool list is not the conversation
56
- * roster's and no single list serves both.
51
+ * The description is the `detail` of the loop event of the call, which the loop
52
+ * computed with the `describeCall` hook of the tool. The phrase never reads the
53
+ * input of the call.
57
54
  *
58
55
  * The name always leads, and that is load-bearing rather than cosmetic. This
59
56
  * phrase is rendered alone, with no tool name beside it — unlike the chat chip,
@@ -61,11 +58,10 @@ export declare function synthesisEmission(runId: string, stepId: string, seq: nu
61
58
  * `edit_file` both describe a call by its path, so a detail on its own would
62
59
  * render a write and an edit of the same file identically.
63
60
  *
64
- * A tool with no hook, an unknown tool, and an input its schema rejects all fall
65
- * back to the name alone. No verb is added: the phrase is carried on a part that
61
+ * An event with no detail falls back to the name alone. No verb is added: the phrase is carried on a part that
66
62
  * already states the phase, so "Running" would only restate it.
67
63
  */
68
- export declare function activityForTool(name: string, input?: unknown, resolveDetail?: ToolDetailResolver): string;
64
+ export declare function activityForTool(name: string, detail?: string): string;
69
65
  /** On-change working-tree delta the sandbox executor posts per exec (paths
70
66
  * only; the executor skips directories). */
71
67
  export type SandboxTreeDelta = {
@@ -54,11 +54,9 @@ export function synthesisEmission(runId, stepId, seq, phase, activity, at, extra
54
54
  * The live-activity phrase for one tool call: the tool's name, then its own
55
55
  * description of this call when it has one.
56
56
  *
57
- * The description comes from the tool's `describeCall` hook, resolved through
58
- * `resolveDetail` — the same resolver the chat chip and the reloaded transcript
59
- * use. A caller supplies the resolver for the agent whose calls it is
60
- * describing, because a sandbox agent's tool list is not the conversation
61
- * roster's and no single list serves both.
57
+ * The description is the `detail` of the loop event of the call, which the loop
58
+ * computed with the `describeCall` hook of the tool. The phrase never reads the
59
+ * input of the call.
62
60
  *
63
61
  * The name always leads, and that is load-bearing rather than cosmetic. This
64
62
  * phrase is rendered alone, with no tool name beside it — unlike the chat chip,
@@ -66,12 +64,10 @@ export function synthesisEmission(runId, stepId, seq, phase, activity, at, extra
66
64
  * `edit_file` both describe a call by its path, so a detail on its own would
67
65
  * render a write and an edit of the same file identically.
68
66
  *
69
- * A tool with no hook, an unknown tool, and an input its schema rejects all fall
70
- * back to the name alone. No verb is added: the phrase is carried on a part that
67
+ * An event with no detail falls back to the name alone. No verb is added: the phrase is carried on a part that
71
68
  * already states the phase, so "Running" would only restate it.
72
69
  */
73
- export function activityForTool(name, input, resolveDetail) {
74
- const detail = resolveDetail?.(name, input);
70
+ export function activityForTool(name, detail) {
75
71
  return detail === undefined ? name : `${name} ${detail}`;
76
72
  }
77
73
  const SandboxTreeDeltaSchema = z.object({
@@ -86,6 +86,8 @@ export type ArtifactPageQuery = {
86
86
  q?: string;
87
87
  /** The whole path, with case. It selects the one row of that path. */
88
88
  path?: string;
89
+ /** The role of the row. `step_output` selects the rows that the grouped run count of {@link countArtifactsByRuns} counts. */
90
+ role?: ArtifactRole;
89
91
  /**
90
92
  * Extensions in lower case, with no dot. A row is selected when the extension of its path is a member. A row with
91
93
  * no extension never matches, and an empty list selects each row. The read compares the members as they are.
@@ -125,7 +127,8 @@ export type ArtifactPage = {
125
127
  * select, grouped by extension, and the page. The count is a separate statement because a page past the end has no
126
128
  * row to carry it. `total` is the sum of the groups.
127
129
  *
128
- * Every row of the analysis is a member, an `input` row and a row with `unrecoverable_at` included. The cost
130
+ * Every row of the analysis is a member, an `input` row and a row with `unrecoverable_at` included, unless the `role`
131
+ * filter selects one role. `total` and `extensionCounts` follow the filters. The cost
129
132
  * grows with the ledger of the one analysis, because the count reads each filtered row.
130
133
  */
131
134
  export declare function queryArtifactPage(pool: Querier, analysisId: string, query: ArtifactPageQuery): ResultAsync<ArtifactPage, DbError>;
@@ -193,7 +193,8 @@ const EXTENSION_SQL = `lower(substring(regexp_replace(path, '^.*/', '') from '^.
193
193
  * select, grouped by extension, and the page. The count is a separate statement because a page past the end has no
194
194
  * row to carry it. `total` is the sum of the groups.
195
195
  *
196
- * Every row of the analysis is a member, an `input` row and a row with `unrecoverable_at` included. The cost
196
+ * Every row of the analysis is a member, an `input` row and a row with `unrecoverable_at` included, unless the `role`
197
+ * filter selects one role. `total` and `extensionCounts` follow the filters. The cost
197
198
  * grows with the ledger of the one analysis, because the count reads each filtered row.
198
199
  */
199
200
  export function queryArtifactPage(pool, analysisId, query) {
@@ -215,6 +216,8 @@ export function queryArtifactPage(pool, analysisId, query) {
215
216
  conditions.push(`strpos(lower(path), lower(${bind(query.q)}::text)) > 0`);
216
217
  if (query.path !== undefined)
217
218
  conditions.push(`path = ${bind(query.path)}`);
219
+ if (query.role !== undefined)
220
+ conditions.push(`role = ${bind(query.role)}`);
218
221
  if (query.extensions !== undefined && query.extensions.length > 0) {
219
222
  conditions.push(`${EXTENSION_SQL} = ANY(${bind([...query.extensions])}::text[])`);
220
223
  }
@@ -16,8 +16,8 @@ setRunSynthesisOutcome, queryRun, queryActiveRun, queryNonTerminalRunsByAnalysis
16
16
  export type { InsertRunInput, RunPage, RunReservation } from "./runs.js";
17
17
  export { insertStepExecution, seedStepExecutions, sweepPendingStepExecutions, updateStepExecution, queryStepsByRun, countStepsByRuns, } from "./step-executions.js";
18
18
  export type { InsertStepExecutionInput, RunStepCounts, SeedStepExecutionRow, UpdateStepExecutionInput } from "./step-executions.js";
19
- export { insertPlan, upsertPlan, loadPlan, loadPlanRecord } from "./plans.js";
20
- export type { InsertPlanInput, UpsertPlanInput, PlanRecord, PlanRowQuerier } from "./plans.js";
19
+ export { insertPlan, upsertPlan, loadPlan, loadPlanRecord, queryPlanPage } from "./plans.js";
20
+ export type { InsertPlanInput, UpsertPlanInput, PlanRecord, PlanRowQuerier, PlanPage, PlanPageEntry } from "./plans.js";
21
21
  export { tryStartDataProfile, tryRetryDataProfile, tryRerunDataProfile, completeDataProfile, failDataProfile, expireStaleDataProfile, reconcileOrphanedDataProfile, clearDataProfile, completeEmptyDataProfile, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, } from "./data-profile.js";
22
22
  export type { DataProfileStatus, DataProfileTerminalWrite, DataProfileResult, DataProfileChecked, DataProfileCompanionCompleteness, DataProfileDimension, DataProfileGroup, DataProfileGroupSlot, DataProfileFile, DataProfileMemberAnnotation, DataProfileObservation, DataProfilePartition, DataProfileProbeReport, DataProfileQualityAssessment, DataProfileQuarantine, DataProfileRecipeStep, DataProfileKind, DataProfileAxis, DataProfileInputSignature, DataProfileCoverage, } from "./data-profile.js";
23
23
  export { queryRunCountsByAnalyses, queryThreadCountsByAnalyses, queryDataProfileStatusByAnalyses, queryLatestRunsByAnalyses, queryRunProgressByRuns, queryRunsEndedSince, } from "./analyses-metrics.js";
@@ -12,7 +12,7 @@ export { upsertArtifact, upsertArtifacts, queryInputArtifacts, queryStepArtifact
12
12
  export { insertRun, reserveRunById, RunDedupCollisionError, RunIdentityCollisionError, updateRunStatus, reconcileOrphanedRuns, ORPHANED_RUN_GRACE_MS, markRunCanceledIfActive, promoteFailedToPartial, setRunMandate, // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
13
13
  setRunSynthesisOutcome, queryRun, queryActiveRun, queryNonTerminalRunsByAnalysis, queryRunsForInspection, queryRunsByAnalysis, countRunsByAnalysis, queryRunsByThread, queryRunIdsByPlan, queryNewestPlanRun, } from "./runs.js";
14
14
  export { insertStepExecution, seedStepExecutions, sweepPendingStepExecutions, updateStepExecution, queryStepsByRun, countStepsByRuns, } from "./step-executions.js";
15
- export { insertPlan, upsertPlan, loadPlan, loadPlanRecord } from "./plans.js";
15
+ export { insertPlan, upsertPlan, loadPlan, loadPlanRecord, queryPlanPage } from "./plans.js";
16
16
  export { tryStartDataProfile, tryRetryDataProfile, tryRerunDataProfile, completeDataProfile, failDataProfile, expireStaleDataProfile, reconcileOrphanedDataProfile, clearDataProfile, completeEmptyDataProfile, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, } from "./data-profile.js";
17
17
  export { queryRunCountsByAnalyses, queryThreadCountsByAnalyses, queryDataProfileStatusByAnalyses, queryLatestRunsByAnalyses, queryRunProgressByRuns, queryRunsEndedSince, } from "./analyses-metrics.js";
18
18
  // Not per-entity: one analysis's whole Postgres footprint, reached from its id
@@ -94,3 +94,30 @@ export type PlanRecord = {
94
94
  export declare function loadPlanRecord(pool: PlanRowQuerier, planId: string, opts: {
95
95
  analysisId: string;
96
96
  }): ResultAsync<PlanRecord | null, DbError>;
97
+ /** One plan of {@link queryPlanPage}. */
98
+ export type PlanPageEntry = {
99
+ planId: string;
100
+ createdAt: string;
101
+ /** The `title` of the stored document when it is a string, as the document holds it, else `null`. */
102
+ title: string | null;
103
+ parentPlanId: string | null;
104
+ /** The newest plan of the same analysis that names this plan as its parent. */
105
+ supersededBy: string | null;
106
+ /** The newest run of the plan, in the order of {@link queryRunIdsByPlan}, or `null` for a plan with no run. */
107
+ newestRunId: string | null;
108
+ };
109
+ /** One page of the plans of an analysis, and the exact count of them. */
110
+ export type PlanPage = {
111
+ entries: PlanPageEntry[];
112
+ total: number;
113
+ };
114
+ /**
115
+ * One page of the plans of an analysis, newest first, in two statements: the count of the plans and the page. The
116
+ * count is a separate statement because a page past the end has no row to carry it. The order is the one of
117
+ * `supersededBy`: the creation time, then the highest plan id. The page gives the title of the document, not the
118
+ * document, because a page holds up to 200 plans.
119
+ */
120
+ export declare function queryPlanPage(pool: Querier, analysisId: string, opts: {
121
+ limit: number;
122
+ offset: number;
123
+ }): ResultAsync<PlanPage, DbError>;
@@ -117,6 +117,13 @@ const PlanRecordRowSchema = z.object({
117
117
  created_at: z.string(),
118
118
  superseded_by: z.string().nullable(),
119
119
  });
120
+ /**
121
+ * The `superseded_by` column of a plan read: the newest plan of the same analysis that names the plan as its parent.
122
+ * The text refers to the plan row of the outer query as `p`.
123
+ */
124
+ const SUPERSEDED_BY_SQL = `(SELECT c.plan_id FROM cortex_plans c
125
+ WHERE c.parent_plan_id = p.plan_id AND c.analysis_id = p.analysis_id
126
+ ORDER BY c.created_at DESC, c.plan_id DESC LIMIT 1) AS superseded_by`;
120
127
  /**
121
128
  * Analysis-scoped read of one plan with its parent and the newest plan that
122
129
  * replaced it, in one statement. Returns `ok(null)` for an unknown planId OR a
@@ -129,9 +136,7 @@ export function loadPlanRecord(pool, planId, opts) {
129
136
  return tryQuery("plans.loadPlanRecord", async () => {
130
137
  const result = await pool.query({
131
138
  text: `SELECT p.plan, p.parent_plan_id, p.created_at,
132
- (SELECT c.plan_id FROM cortex_plans c
133
- WHERE c.parent_plan_id = p.plan_id AND c.analysis_id = p.analysis_id
134
- ORDER BY c.created_at DESC, c.plan_id DESC LIMIT 1) AS superseded_by
139
+ ${SUPERSEDED_BY_SQL}
135
140
  FROM cortex_plans p
136
141
  WHERE p.plan_id = $1 AND p.analysis_id = $2`,
137
142
  values: [planId, opts.analysisId],
@@ -143,3 +148,52 @@ export function loadPlanRecord(pool, planId, opts) {
143
148
  return { planId, plan: row.plan, parentPlanId: row.parent_plan_id, supersededBy: row.superseded_by, createdAt: row.created_at };
144
149
  });
145
150
  }
151
+ const PlanPageRowSchema = z.object({
152
+ plan_id: z.string(),
153
+ created_at: z.string(),
154
+ title: z.string().nullable(),
155
+ parent_plan_id: z.string().nullable(),
156
+ superseded_by: z.string().nullable(),
157
+ newest_run_id: z.string().nullable(),
158
+ });
159
+ /**
160
+ * One page of the plans of an analysis, newest first, in two statements: the count of the plans and the page. The
161
+ * count is a separate statement because a page past the end has no row to carry it. The order is the one of
162
+ * `supersededBy`: the creation time, then the highest plan id. The page gives the title of the document, not the
163
+ * document, because a page holds up to 200 plans.
164
+ */
165
+ export function queryPlanPage(pool, analysisId, opts) {
166
+ return tryQuery("plans.queryPlanPage", async () => {
167
+ const countResult = await pool.query({
168
+ text: "SELECT COUNT(*)::int AS total FROM cortex_plans WHERE analysis_id = $1",
169
+ values: [analysisId],
170
+ });
171
+ const rowsResult = await pool.query({
172
+ text: `SELECT p.plan_id, p.created_at, p.parent_plan_id,
173
+ CASE WHEN jsonb_typeof(p.plan->'title') = 'string' THEN p.plan->>'title' END AS title,
174
+ ${SUPERSEDED_BY_SQL},
175
+ (SELECT r.run_id FROM cortex_runs r
176
+ WHERE r.analysis_id = p.analysis_id AND r.plan_id = p.plan_id
177
+ ORDER BY r.started_at DESC, r.run_id DESC LIMIT 1) AS newest_run_id
178
+ FROM cortex_plans p
179
+ WHERE p.analysis_id = $1
180
+ ORDER BY p.created_at DESC, p.plan_id DESC
181
+ LIMIT $2 OFFSET $3`,
182
+ values: [analysisId, opts.limit, opts.offset],
183
+ });
184
+ return {
185
+ entries: rowsResult.rows.map((raw) => {
186
+ const row = PlanPageRowSchema.parse(raw);
187
+ return {
188
+ planId: row.plan_id,
189
+ createdAt: row.created_at,
190
+ title: row.title,
191
+ parentPlanId: row.parent_plan_id,
192
+ supersededBy: row.superseded_by,
193
+ newestRunId: row.newest_run_id,
194
+ };
195
+ }),
196
+ total: countResult.rows[0]?.total ?? 0,
197
+ };
198
+ });
199
+ }