@particle-academy/prism-harness 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -108,13 +108,59 @@ to your provider call (with `prism-ts`, `withProviderOptions()`):
108
108
 
109
109
  ```ts
110
110
  new ModeRegistry({
111
- modes: { overseer: { system_prompt: '...', provider_options: { thinking: { enabled: true } } } },
111
+ modes: { overseer: { system_prompt: '...', provider_options: { thinking: { type: 'adaptive' } } } },
112
112
  });
113
113
  ```
114
114
 
115
115
  A value that is not a map is refused when the mode is resolved, with
116
116
  `mode_malformed`.
117
117
 
118
+ ## What your model client receives
119
+
120
+ `request.messages` is the thread, oldest first, in the shape the PHP reference
121
+ stores Prism's messages:
122
+
123
+ | `type` | Carries |
124
+ |---|---|
125
+ | `user` | `content`, and `additional_content` when the turn has attachments |
126
+ | `assistant` | `content`, `tool_calls` (`id`, `name`, `arguments`, `result_id`, `reasoning_id`, `reasoning_summary`), `additional_content`, `tool_approval_requests` |
127
+ | `tool_result` | `tool_results` (`tool_call_id`, `tool_name`, `args`, `result`, `tool_call_result_id`, `artifacts`), `tool_approval_responses` |
128
+
129
+ Consecutive tool result rows reach the client as one, so each call's result is
130
+ sent to the provider once. Return a call's provider ids as `resultId`,
131
+ `reasoningId` and `reasoningSummary`, and provider state for the turn as
132
+ `additionalContent`: they are recorded and come back in `messages`.
133
+
134
+ ## Approvals
135
+
136
+ A mode names the tools a person must approve:
137
+
138
+ ```ts
139
+ guarded: { system_prompt: '...', tools: ['read', 'delete'], requires_approval: ['delete'] },
140
+ ```
141
+
142
+ A step that calls one stops with `finishReason: 'awaiting_approval'`. The calls
143
+ that need nobody have already run. Record a decision for every pending approval,
144
+ then resume with an empty prompt:
145
+
146
+ ```ts
147
+ const response = await runtime.send(session, 'Clean up the failed run');
148
+
149
+ if (response.finishReason === 'awaiting_approval') {
150
+ for (const pending of response.pendingApprovals) {
151
+ await recordApproval(session, pending.id, true); // or false, 'not on production'
152
+ }
153
+
154
+ await runtime.send(session, '');
155
+ }
156
+ ```
157
+
158
+ On resume the approved calls run once and denied calls return their reason, and
159
+ then the model continues. The model is not asked to make the calls again. A
160
+ pending call with no decision is refused with "No approval response provided".
161
+ A call that has a result never runs again. Who may approve is your application's
162
+ decision: authorize before calling `recordApproval()`.
163
+
118
164
  ## Agent task lists
119
165
 
120
166
  An agent given a goal has to keep working across many requests. It needs a list
package/dist/index.d.ts CHANGED
@@ -24,6 +24,8 @@ export type { HarnessEvent, HarnessListener, RunFailed, RunFinished, RunStarted
24
24
  export { diagnose } from './doctor.js';
25
25
  export type { DoctorFinding, DoctorReport, DoctorSubjects } from './doctor.js';
26
26
  export { AgentRuntime, recordApproval } from './runtime.js';
27
+ export { assistantRow, threadView, toolResultEntry, toolResultRow } from './thread-rows.js';
28
+ export type { ApprovalDecisionEntry, ApprovalRequestEntry, AssistantRow, ToolCallInput, ToolCallRow, ToolResultEntry, ToolResultRow, } from './thread-rows.js';
27
29
  export type { AgentResponse, AgentRuntimeOptions, LlmClient, LlmRequest, LlmResponse, LlmToolCall, PendingApproval, } from './runtime.js';
28
30
  export { DEFAULT_LEASE_SECONDS, TASK_STATES, asAgentTask, attributesToRecord, canonicalTaskJson, isTaskState, isTerminalState, recordToAttributes, taskRecordFrom, withAgentTask, } from './tasks.js';
29
31
  export type { AgentTask, AgentTaskAttributeSource, AgentTaskAttributes, AgentTaskRecord, AgentTaskSource, LeasedAgentTask, TaskOutcome, TaskState, } from './tasks.js';
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ export { ToolAuthorizer, ToolRegistry, authorizedTool } from './tools.js';
13
13
  export { HarnessEvents } from './events.js';
14
14
  export { diagnose } from './doctor.js';
15
15
  export { AgentRuntime, recordApproval } from './runtime.js';
16
+ export { assistantRow, threadView, toolResultEntry, toolResultRow } from './thread-rows.js';
16
17
  export { DEFAULT_LEASE_SECONDS, TASK_STATES, asAgentTask, attributesToRecord, canonicalTaskJson, isTaskState, isTerminalState, recordToAttributes, taskRecordFrom, withAgentTask, } from './tasks.js';
17
18
  export { StoreTaskSource, StoredAgentTask } from './store-task-source.js';
18
19
  export { agentCompletionTool } from './task-tools.js';
package/dist/runtime.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { JsonObject } from './json.js';
1
+ import type { JsonObject, JsonValue } from './json.js';
2
2
  import type { HarnessEvents } from './events.js';
3
3
  import type { ModeRegistry } from './modes.js';
4
4
  import type { Session } from './session.js';
@@ -33,6 +33,15 @@ export interface LlmToolCall {
33
33
  id: string;
34
34
  name: string;
35
35
  arguments: JsonObject;
36
+ /**
37
+ * The ids a provider keys the call's result and reasoning by, when they differ
38
+ * from `id`. OpenAI's Responses API answers a `function_call` by its `call_id`,
39
+ * and replays the reasoning item it came from by id. Recorded on the call, as
40
+ * prism's ToolCall stores them, so the next request can send them back.
41
+ */
42
+ resultId?: string | null;
43
+ reasoningId?: string | null;
44
+ reasoningSummary?: readonly JsonValue[] | null;
36
45
  }
37
46
  export interface LlmResponse {
38
47
  text: string;
@@ -41,6 +50,14 @@ export interface LlmResponse {
41
50
  finishReason: string;
42
51
  /** Null when the provider does not report one. NOT zero — see `RunLedger.recordCost`. */
43
52
  costUsd?: number | null;
53
+ /**
54
+ * What the provider needs sent back with this turn on the next request, such
55
+ * as Anthropic's `thinking` and `thinking_signature`. Recorded with the
56
+ * assistant turn as `additional_content`, the key prism's AssistantMessage
57
+ * uses, so a client built on prism-ts can pass `response.additionalContent`
58
+ * straight through and read it back from `messages`.
59
+ */
60
+ additionalContent?: Readonly<JsonObject>;
44
61
  }
45
62
  export type LlmClient = (request: LlmRequest) => Promise<LlmResponse>;
46
63
  export interface AgentResponse {
@@ -56,7 +73,9 @@ export interface AgentResponse {
56
73
  stoppedBecause: string | null;
57
74
  }
58
75
  export interface PendingApproval {
76
+ /** The APPROVAL id: what `recordApproval()` answers. Not the tool call id. */
59
77
  id: string;
78
+ toolCallId: string;
60
79
  tool: string;
61
80
  arguments: JsonObject;
62
81
  }
@@ -67,24 +86,6 @@ export interface AgentRuntimeOptions {
67
86
  authorizer?: ToolAuthorizer;
68
87
  events?: HarnessEvents;
69
88
  }
70
- /**
71
- * The loop: prompt in, turns out, everything recorded.
72
- *
73
- * Three properties matter more than the mechanics.
74
- *
75
- * **Every step is checked against the budget BEFORE it is taken**, not after.
76
- * Checking afterwards means the step that broke the limit has already been
77
- * paid for, which makes a budget a report rather than a control.
78
- *
79
- * **An approval stops the run and is written to the THREAD**, not held in
80
- * memory. That is what makes it survive: the approval a person grants this
81
- * morning is a durable row, so the worker that resumes tonight — a different
82
- * process, possibly after a deploy — reads the same answer.
83
- *
84
- * **A tool that needs approval and has none DOES NOT RUN.** Failing closed is
85
- * the only safe direction: an unanswered approval that executed anyway is
86
- * exactly the outcome the whole mechanism exists to prevent.
87
- */
88
89
  export declare class AgentRuntime {
89
90
  #private;
90
91
  constructor(options: AgentRuntimeOptions);
@@ -106,5 +107,11 @@ export declare class AgentRuntime {
106
107
  * already scoped to a participant, so nobody can answer another participant's
107
108
  * approval through it, but "this user may approve THIS action" is a question
108
109
  * only the host can answer. Authorize before calling.
110
+ *
111
+ * Nothing runs until the next `send()`, which acts on every decision recorded
112
+ * by then and refuses any pending call still without one. With several pending,
113
+ * record them all first.
114
+ *
115
+ * `approvalId` is `PendingApproval.id`, not the tool call id.
109
116
  */
110
117
  export declare function recordApproval(session: Session, approvalId: string, approved: boolean, reason?: string | null): Promise<void>;
package/dist/runtime.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { assistantRow, threadView, toolResultEntry, toolResultRow, } from './thread-rows.js';
2
3
  import { admitAttachments } from './attachments.js';
3
4
  import { HarnessError } from './errors.js';
4
5
  import { RunBudget, RunContext } from './subagents.js';
@@ -20,6 +21,10 @@ import { RunBudget, RunContext } from './subagents.js';
20
21
  * the only safe direction: an unanswered approval that executed anyway is
21
22
  * exactly the outcome the whole mechanism exists to prevent.
22
23
  */
24
+ /** How long resolving approvals may hold the session lock: long enough for a tool to run. */
25
+ const RESOLUTION_LOCK_SECONDS = 300;
26
+ /** How long a second worker waits for that lock before giving up without running anything. */
27
+ const RESOLUTION_WAIT_SECONDS = 30;
23
28
  export class AgentRuntime {
24
29
  #client;
25
30
  #modes;
@@ -67,17 +72,24 @@ export class AgentRuntime {
67
72
  depth: run.depth,
68
73
  at: new Date().toISOString(),
69
74
  });
70
- if (prompt !== '') {
71
- // With attachments, the shape prism-ts's UserMessage.toObject() writes: the
72
- // media parts, then the turn's own text as a trailing text part, which
73
- // fromObject() strips back off. Without them, unchanged.
74
- const turn = attachments.length === 0
75
- ? { type: 'user', content: prompt }
76
- : { type: 'user', content: prompt, additional_content: [...attachments, { text: prompt }], additional_attributes: {} };
77
- await thread.record([turn], runId);
78
- }
79
75
  try {
80
- return await this.#loop(session, mode, run, runId, provider, model, toolNames);
76
+ const resolved = await this.#tools.resolve(toolNames ?? mode.tools, session);
77
+ const offered = this.#authorizer ? await this.#authorizer.allowed(session, resolved) : [...resolved.values()];
78
+ const called = [];
79
+ // Decisions recorded since the run stopped are acted on FIRST, before a
80
+ // new prompt is recorded, so the results land after the calls they answer
81
+ // rather than after the new turn.
82
+ await this.#resolveApprovals(session, mode, offered, runId, called);
83
+ if (prompt !== '') {
84
+ // With attachments, the shape prism-ts's UserMessage.toObject() writes:
85
+ // the media parts, then the turn's own text as a trailing text part,
86
+ // which fromObject() strips back off. Without them, unchanged.
87
+ const turn = attachments.length === 0
88
+ ? { type: 'user', content: prompt }
89
+ : { type: 'user', content: prompt, additional_content: [...attachments, { text: prompt }], additional_attributes: {} };
90
+ await thread.record([turn], runId);
91
+ }
92
+ return await this.#loop(session, mode, run, runId, provider, model, offered, called);
81
93
  }
82
94
  catch (error) {
83
95
  const failure = error instanceof Error ? error.message : String(error);
@@ -93,13 +105,8 @@ export class AgentRuntime {
93
105
  throw error;
94
106
  }
95
107
  }
96
- async #loop(session, mode, run, runId, provider, model, toolNames) {
108
+ async #loop(session, mode, run, runId, provider, model, offered, called) {
97
109
  const thread = session.thread();
98
- const resolved = await this.#tools.resolve(toolNames ?? mode.tools, session);
99
- const offered = this.#authorizer
100
- ? await this.#authorizer.allowed(session, resolved)
101
- : [...resolved.values()];
102
- const called = [];
103
110
  let text = '';
104
111
  let finishReason = 'stop';
105
112
  for (;;) {
@@ -111,7 +118,7 @@ export class AgentRuntime {
111
118
  }
112
119
  const response = await this.#client({
113
120
  systemPrompt: mode.systemPrompt,
114
- messages: (await thread.messages()).map((entry) => entry.message),
121
+ messages: threadView((await thread.messages()).map((entry) => entry.message)),
115
122
  tools: offered,
116
123
  provider,
117
124
  model,
@@ -122,64 +129,138 @@ export class AgentRuntime {
122
129
  text = response.text;
123
130
  finishReason = response.finishReason;
124
131
  const toolCalls = response.toolCalls ?? [];
125
- await thread.record([
126
- {
127
- type: 'assistant',
128
- content: response.text,
129
- tool_calls: toolCalls.map((call) => ({ id: call.id, name: call.name })),
130
- },
131
- ], runId);
132
+ const gated = toolCalls.filter((call) => mode.needsApproval(call.name));
133
+ const requests = gated.map((call) => ({
134
+ approval_id: `apr_${randomUUID().replaceAll('-', '')}`,
135
+ tool_call_id: call.id,
136
+ }));
137
+ // The next step's request is built from this row, so it keeps what a
138
+ // provider needs sent back: each call's arguments and provider ids, the
139
+ // turn's provider state, and the approvals it is waiting on (G-58).
140
+ await thread.record([assistantRow(response.text, toolCalls, response.additionalContent, requests)], runId);
132
141
  if (toolCalls.length === 0) {
133
142
  return await this.#finish(session, runId, called, finishReason, text, run, null);
134
143
  }
135
- const pending = await this.#pendingApprovals(session, mode, toolCalls);
136
- if (pending.length > 0) {
137
- // FAILS CLOSED. The run stops here and the request is already in the
138
- // thread, so a different process can pick it up after a human answers.
139
- await thread.record([{ type: 'tool_approval_request', approvals: pending }], runId);
144
+ // The calls that need nobody run now, as in the reference, and their
145
+ // results are recorded even when the step then stops for a person.
146
+ const results = [];
147
+ for (const call of toolCalls.filter((candidate) => !gated.includes(candidate))) {
148
+ called.push(call.name);
149
+ results.push(await this.#invoke(offered, call));
150
+ }
151
+ if (results.length > 0) {
152
+ await thread.record([toolResultRow(results)], runId);
153
+ }
154
+ if (requests.length > 0) {
155
+ // FAILS CLOSED. The gated calls have not run, and the requests are in
156
+ // the thread, so a different process can resume after a person answers.
140
157
  return {
141
158
  runId,
142
159
  text,
143
160
  steps: run.ledger.steps,
144
161
  toolCalls: called,
145
162
  finishReason: 'awaiting_approval',
146
- pendingApprovals: pending,
163
+ pendingApprovals: gated.map((call, index) => ({
164
+ id: requests[index].approval_id,
165
+ toolCallId: call.id,
166
+ tool: call.name,
167
+ arguments: call.arguments,
168
+ })),
147
169
  stoppedBecause: null,
148
170
  };
149
171
  }
150
- for (const call of toolCalls) {
151
- called.push(call.name);
152
- await thread.record([await this.#invoke(offered, call)], runId);
153
- }
154
172
  }
155
173
  }
156
174
  /**
157
- * Which of these calls needs a human, and has not had one.
175
+ * Act on the decisions recorded for the last turn that stopped for a person.
176
+ *
177
+ * The model is NOT asked again. Asked again, a provider issues the call
178
+ * afresh under a new id, and a decision recorded against the old one never
179
+ * matches. The calls that stopped the run are answered where they are:
158
180
  *
159
- * An approval already answered in the thread is NOT asked again — that is the
160
- * whole point of recording it durably. An answered-and-denied approval is
161
- * also not asked again; it is simply not executed.
181
+ * - approved: run, once;
182
+ * - denied: the reason, as the result the model sees;
183
+ * - no decision: refused, "No approval response provided". Record every
184
+ * decision before resuming.
185
+ *
186
+ * A call that already has a result is done and never runs again, whatever
187
+ * its decision says. The results are recorded as one tool result row holding
188
+ * every result and decision for the turn, as the reference writes it.
189
+ */
190
+ async #resolveApprovals(session, mode, offered, runId, called) {
191
+ // A read without the lock first: nearly every send() has nothing to resolve
192
+ // and should not wait on the session lock to find that out.
193
+ if ((await this.#unresolved(session, mode)) === null)
194
+ return;
195
+ // Then under the session lock, reading again inside it. Two workers resuming
196
+ // the same session at once would otherwise both find the approved call with
197
+ // no result, and both run it. The second now waits, finds the result the
198
+ // first recorded, and runs nothing. If it cannot get the lock it throws
199
+ // SessionLocked, and nothing runs.
200
+ await session.lock(async (live) => {
201
+ const work = await this.#unresolved(live, mode);
202
+ if (work === null)
203
+ return;
204
+ const resolved = [];
205
+ for (const call of work.calls) {
206
+ const input = { id: call.id, name: call.name, arguments: call.arguments, resultId: call.result_id };
207
+ const approvalId = work.approvalIds.get(call.id);
208
+ const decision = approvalId === undefined ? undefined : work.decisions.get(approvalId);
209
+ if (decision?.approved === true) {
210
+ called.push(call.name);
211
+ resolved.push(await this.#runApproved(offered, input));
212
+ }
213
+ else {
214
+ resolved.push(toolResultEntry(input, decision === undefined ? 'No approval response provided' : (decision.reason ?? 'User denied tool execution')));
215
+ }
216
+ }
217
+ await live.thread().record([toolResultRow([...work.results.values(), ...resolved], [...work.decisions.values()])], runId);
218
+ }, RESOLUTION_LOCK_SECONDS, RESOLUTION_WAIT_SECONDS);
219
+ }
220
+ /**
221
+ * The gated calls of the last tool-calling turn that have no result yet, with
222
+ * what is needed to answer them, or null when there are none.
162
223
  */
163
- async #pendingApprovals(session, mode, toolCalls) {
164
- const gated = toolCalls.filter((call) => mode.needsApproval(call.name));
165
- if (gated.length === 0)
166
- return [];
167
- const answered = await this.#answeredApprovals(session);
168
- return gated
169
- .filter((call) => !answered.has(call.id))
170
- .map((call) => ({ id: call.id, tool: call.name, arguments: call.arguments }));
224
+ async #unresolved(session, mode) {
225
+ const view = threadView((await session.thread().messages()).map((entry) => entry.message));
226
+ let index = view.length - 1;
227
+ while (index >= 0 && !(view[index].type === 'assistant' && view[index].tool_calls.length > 0)) {
228
+ index -= 1;
229
+ }
230
+ if (index === -1)
231
+ return null;
232
+ const assistant = view[index];
233
+ const answered = view.slice(index + 1).find((row) => row.type === 'tool_result');
234
+ const results = new Map((answered?.tool_results ?? []).map((entry) => [entry.tool_call_id, entry]));
235
+ const decisions = new Map((answered?.tool_approval_responses ?? []).map((entry) => [entry.approval_id, entry]));
236
+ const approvalIds = new Map(assistant.tool_approval_requests.map((request) => [request.tool_call_id, request.approval_id]));
237
+ // A call that has a result is DONE, whatever its decision says. A call that
238
+ // needs nobody is not this method's to run.
239
+ const calls = assistant.tool_calls.filter((call) => !results.has(call.id) && (approvalIds.has(call.id) || mode.needsApproval(call.name)));
240
+ return calls.length === 0 ? null : { calls, results, decisions, approvalIds };
171
241
  }
172
- async #answeredApprovals(session) {
173
- const answered = new Map();
174
- for (const entry of await session.thread().messages()) {
175
- if (entry.message.type !== 'tool_approval_response')
176
- continue;
177
- const id = entry.message.approval_id;
178
- const approved = entry.message.approved;
179
- if (typeof id === 'string')
180
- answered.set(id, approved === true);
242
+ /**
243
+ * Run a call a person approved, or record why it could not run.
244
+ *
245
+ * Recorded rather than thrown. Resolution runs at the start of every send(),
246
+ * so a call that throws here, because its tool is no longer offered to this
247
+ * run or the authorizer now refuses it, would throw again on every later
248
+ * send(), and the session could never move on. The approval stands, but the
249
+ * call does not run.
250
+ */
251
+ async #runApproved(offered, call) {
252
+ if (!offered.some((tool) => tool.name === call.name)) {
253
+ return toolResultEntry(call, `Not run: ${call.name} is not available to this run.`);
254
+ }
255
+ try {
256
+ return await this.#invoke(offered, call);
257
+ }
258
+ catch (error) {
259
+ if (error instanceof HarnessError && error.code === 'call_not_authorized') {
260
+ return toolResultEntry(call, `Not run: this call to ${call.name} is not authorized.`);
261
+ }
262
+ throw error;
181
263
  }
182
- return answered;
183
264
  }
184
265
  async #invoke(offered, call) {
185
266
  const tool = offered.find((candidate) => candidate.name === call.name);
@@ -188,12 +269,7 @@ export class AgentRuntime {
188
269
  }
189
270
  try {
190
271
  const result = await tool.handle(call.arguments);
191
- return {
192
- type: 'tool_result',
193
- tool_call_id: call.id,
194
- name: call.name,
195
- result: typeof result === 'string' ? result : JSON.stringify(result ?? null),
196
- };
272
+ return toolResultEntry(call, typeof result === 'string' ? result : JSON.stringify(result ?? null));
197
273
  }
198
274
  catch (error) {
199
275
  // A failed tool is a RESULT, not a crashed run: the model can often
@@ -201,13 +277,7 @@ export class AgentRuntime {
201
277
  // call is different and is left to propagate — see `authorizedTool`.
202
278
  if (error instanceof HarnessError && error.code === 'call_not_authorized')
203
279
  throw error;
204
- return {
205
- type: 'tool_result',
206
- tool_call_id: call.id,
207
- name: call.name,
208
- result: `The tool failed: ${error instanceof Error ? error.message : String(error)}`,
209
- failed: true,
210
- };
280
+ return toolResultEntry(call, `The tool failed: ${error instanceof Error ? error.message : String(error)}`);
211
281
  }
212
282
  }
213
283
  async #finish(session, runId, called, finishReason, text, run, stoppedBecause) {
@@ -241,15 +311,14 @@ export class AgentRuntime {
241
311
  * already scoped to a participant, so nobody can answer another participant's
242
312
  * approval through it, but "this user may approve THIS action" is a question
243
313
  * only the host can answer. Authorize before calling.
314
+ *
315
+ * Nothing runs until the next `send()`, which acts on every decision recorded
316
+ * by then and refuses any pending call still without one. With several pending,
317
+ * record them all first.
318
+ *
319
+ * `approvalId` is `PendingApproval.id`, not the tool call id.
244
320
  */
245
321
  export async function recordApproval(session, approvalId, approved, reason = null) {
246
322
  const run = await session.run();
247
- await session.thread().record([
248
- {
249
- type: 'tool_approval_response',
250
- approval_id: approvalId,
251
- approved,
252
- reason,
253
- },
254
- ], run?.id ?? null);
323
+ await session.thread().record([toolResultRow([], [{ approval_id: approvalId, approved, reason }])], run?.id ?? null);
255
324
  }
@@ -0,0 +1,71 @@
1
+ import type { JsonObject, JsonValue } from './json.js';
2
+ /**
3
+ * The rows a run writes to a thread, in the shape the PHP reference stores.
4
+ *
5
+ * prism-harness stores Prism's own messages: an assistant turn carries its tool
6
+ * calls (arguments and provider ids included), its provider state and its
7
+ * approval requests; a tool result turn carries every result of a step and any
8
+ * approval decisions. These builders write exactly that, with the row's `type`
9
+ * beside it, so a thread written here reads the same as one written there.
10
+ * prism-parity's `harness-thread-rows` corpus pins it.
11
+ */
12
+ export type ToolCallRow = {
13
+ id: string;
14
+ name: string;
15
+ arguments: JsonObject;
16
+ result_id: string | null;
17
+ reasoning_id: string | null;
18
+ reasoning_summary: JsonValue[] | null;
19
+ };
20
+ export type ToolResultEntry = {
21
+ tool_call_id: string;
22
+ tool_name: string;
23
+ args: JsonObject;
24
+ result: string;
25
+ tool_call_result_id: string | null;
26
+ artifacts: JsonValue[];
27
+ };
28
+ export type ApprovalRequestEntry = {
29
+ approval_id: string;
30
+ tool_call_id: string;
31
+ };
32
+ export type ApprovalDecisionEntry = {
33
+ approval_id: string;
34
+ approved: boolean;
35
+ reason: string | null;
36
+ };
37
+ export type AssistantRow = {
38
+ type: 'assistant';
39
+ content: string;
40
+ tool_calls: ToolCallRow[];
41
+ additional_content: JsonObject;
42
+ tool_approval_requests: ApprovalRequestEntry[];
43
+ };
44
+ export type ToolResultRow = {
45
+ type: 'tool_result';
46
+ tool_results: ToolResultEntry[];
47
+ tool_approval_responses: ApprovalDecisionEntry[];
48
+ };
49
+ export interface ToolCallInput {
50
+ id: string;
51
+ name: string;
52
+ arguments: JsonObject;
53
+ resultId?: string | null;
54
+ reasoningId?: string | null;
55
+ reasoningSummary?: readonly JsonValue[] | null;
56
+ }
57
+ export declare function assistantRow(content: string, toolCalls: readonly ToolCallInput[], additionalContent?: Readonly<JsonObject>, approvalRequests?: readonly ApprovalRequestEntry[]): AssistantRow;
58
+ export declare function toolResultEntry(call: ToolCallInput, result: string): ToolResultEntry;
59
+ export declare function toolResultRow(results: readonly ToolResultEntry[], decisions?: readonly ApprovalDecisionEntry[]): ToolResultRow;
60
+ /**
61
+ * The thread as the model is shown it: rows written by 0.3.0 and earlier read
62
+ * in today's shape, and consecutive tool result rows folded into one.
63
+ *
64
+ * An approval leaves several tool result rows in a row: the results of the
65
+ * calls a stopped step did run, the decisions, and the results written when the
66
+ * run resumes. Rows are never rewritten, so all of them stay stored; shown to a
67
+ * provider separately, a tool output would go twice. Folded, a result is keyed
68
+ * by its tool call id and a decision by its approval id, and a later one
69
+ * replaces an earlier one in the earlier one's position.
70
+ */
71
+ export declare function threadView(rows: readonly JsonObject[]): JsonObject[];
@@ -0,0 +1,135 @@
1
+ export function assistantRow(content, toolCalls, additionalContent = {}, approvalRequests = []) {
2
+ return {
3
+ type: 'assistant',
4
+ content,
5
+ tool_calls: toolCalls.map((call) => ({
6
+ id: call.id,
7
+ name: call.name,
8
+ arguments: { ...call.arguments },
9
+ result_id: call.resultId ?? null,
10
+ reasoning_id: call.reasoningId ?? null,
11
+ reasoning_summary: call.reasoningSummary ? [...call.reasoningSummary] : null,
12
+ })),
13
+ additional_content: { ...additionalContent },
14
+ tool_approval_requests: approvalRequests.map((request) => ({ ...request })),
15
+ };
16
+ }
17
+ export function toolResultEntry(call, result) {
18
+ return {
19
+ tool_call_id: call.id,
20
+ tool_name: call.name,
21
+ args: { ...call.arguments },
22
+ result,
23
+ tool_call_result_id: call.resultId ?? null,
24
+ artifacts: [],
25
+ };
26
+ }
27
+ export function toolResultRow(results, decisions = []) {
28
+ return {
29
+ type: 'tool_result',
30
+ tool_results: results.map((entry) => ({ ...entry })),
31
+ tool_approval_responses: decisions.map((decision) => ({ ...decision })),
32
+ };
33
+ }
34
+ /**
35
+ * The thread as the model is shown it: rows written by 0.3.0 and earlier read
36
+ * in today's shape, and consecutive tool result rows folded into one.
37
+ *
38
+ * An approval leaves several tool result rows in a row: the results of the
39
+ * calls a stopped step did run, the decisions, and the results written when the
40
+ * run resumes. Rows are never rewritten, so all of them stay stored; shown to a
41
+ * provider separately, a tool output would go twice. Folded, a result is keyed
42
+ * by its tool call id and a decision by its approval id, and a later one
43
+ * replaces an earlier one in the earlier one's position.
44
+ */
45
+ export function threadView(rows) {
46
+ const view = [];
47
+ for (const row of rows) {
48
+ const normalised = normalise(row, view);
49
+ if (normalised === null)
50
+ continue;
51
+ const previous = view.at(-1);
52
+ if (normalised.type === 'tool_result' && previous?.type === 'tool_result') {
53
+ view[view.length - 1] = fold(previous, normalised);
54
+ }
55
+ else {
56
+ view.push(normalised);
57
+ }
58
+ }
59
+ return view;
60
+ }
61
+ function normalise(row, view) {
62
+ switch (row.type) {
63
+ case 'assistant':
64
+ return assistantRow(typeof row.content === 'string' ? row.content : '', list(row.tool_calls).map((call) => ({
65
+ id: text(call.id),
66
+ name: text(call.name),
67
+ arguments: isObject(call.arguments) ? call.arguments : {},
68
+ resultId: nullableText(call.result_id),
69
+ reasoningId: nullableText(call.reasoning_id),
70
+ reasoningSummary: Array.isArray(call.reasoning_summary) ? call.reasoning_summary : null,
71
+ })), isObject(row.additional_content) ? row.additional_content : {}, list(row.tool_approval_requests).map((request) => ({
72
+ approval_id: text(request.approval_id),
73
+ tool_call_id: text(request.tool_call_id),
74
+ })));
75
+ case 'tool_result':
76
+ if (Array.isArray(row.tool_results) || Array.isArray(row.tool_approval_responses)) {
77
+ return toolResultRow(list(row.tool_results).map((entry) => ({
78
+ tool_call_id: text(entry.tool_call_id),
79
+ tool_name: text(entry.tool_name),
80
+ args: isObject(entry.args) ? entry.args : {},
81
+ result: text(entry.result),
82
+ tool_call_result_id: nullableText(entry.tool_call_result_id),
83
+ artifacts: Array.isArray(entry.artifacts) ? entry.artifacts : [],
84
+ })), list(row.tool_approval_responses).map(decision));
85
+ }
86
+ // 0.3.0 and earlier: one row per call, with no arguments on it.
87
+ return toolResultRow([
88
+ {
89
+ tool_call_id: text(row.tool_call_id),
90
+ tool_name: text(row.name),
91
+ args: {},
92
+ result: text(row.result),
93
+ tool_call_result_id: null,
94
+ artifacts: [],
95
+ },
96
+ ]);
97
+ case 'tool_approval_request': {
98
+ // 0.3.0 and earlier kept the request in its own row, keyed by the CALL id.
99
+ // It belongs to the assistant turn before it.
100
+ const assistant = [...view].reverse().find((candidate) => candidate.type === 'assistant');
101
+ for (const approval of list(row.approvals)) {
102
+ assistant?.tool_approval_requests.push({ approval_id: text(approval.id), tool_call_id: text(approval.id) });
103
+ }
104
+ return null;
105
+ }
106
+ case 'tool_approval_response':
107
+ return toolResultRow([], [decision(row)]);
108
+ default:
109
+ return row;
110
+ }
111
+ }
112
+ function fold(earlier, later) {
113
+ const results = new Map();
114
+ const decisions = new Map();
115
+ for (const entry of [...earlier.tool_results, ...later.tool_results])
116
+ results.set(entry.tool_call_id, entry);
117
+ for (const entry of [...earlier.tool_approval_responses, ...later.tool_approval_responses])
118
+ decisions.set(entry.approval_id, entry);
119
+ return toolResultRow([...results.values()], [...decisions.values()]);
120
+ }
121
+ function decision(value) {
122
+ return { approval_id: text(value.approval_id), approved: value.approved === true, reason: nullableText(value.reason) };
123
+ }
124
+ function list(value) {
125
+ return Array.isArray(value) ? value.filter(isObject) : [];
126
+ }
127
+ function isObject(value) {
128
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
129
+ }
130
+ function text(value) {
131
+ return typeof value === 'string' ? value : value === undefined || value === null ? '' : JSON.stringify(value);
132
+ }
133
+ function nullableText(value) {
134
+ return typeof value === 'string' ? value : null;
135
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@particle-academy/prism-harness",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Durable agent sessions for TypeScript — threads, session state and store drivers.",
5
5
  "type": "module",
6
6
  "sideEffects": false,