@particle-academy/prism-harness 0.3.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
@@ -115,6 +115,52 @@ new ModeRegistry({
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;
@@ -64,7 +73,9 @@ export interface AgentResponse {
64
73
  stoppedBecause: string | null;
65
74
  }
66
75
  export interface PendingApproval {
76
+ /** The APPROVAL id: what `recordApproval()` answers. Not the tool call id. */
67
77
  id: string;
78
+ toolCallId: string;
68
79
  tool: string;
69
80
  arguments: JsonObject;
70
81
  }
@@ -75,24 +86,6 @@ export interface AgentRuntimeOptions {
75
86
  authorizer?: ToolAuthorizer;
76
87
  events?: HarnessEvents;
77
88
  }
78
- /**
79
- * The loop: prompt in, turns out, everything recorded.
80
- *
81
- * Three properties matter more than the mechanics.
82
- *
83
- * **Every step is checked against the budget BEFORE it is taken**, not after.
84
- * Checking afterwards means the step that broke the limit has already been
85
- * paid for, which makes a budget a report rather than a control.
86
- *
87
- * **An approval stops the run and is written to the THREAD**, not held in
88
- * memory. That is what makes it survive: the approval a person grants this
89
- * morning is a durable row, so the worker that resumes tonight — a different
90
- * process, possibly after a deploy — reads the same answer.
91
- *
92
- * **A tool that needs approval and has none DOES NOT RUN.** Failing closed is
93
- * the only safe direction: an unanswered approval that executed anyway is
94
- * exactly the outcome the whole mechanism exists to prevent.
95
- */
96
89
  export declare class AgentRuntime {
97
90
  #private;
98
91
  constructor(options: AgentRuntimeOptions);
@@ -114,5 +107,11 @@ export declare class AgentRuntime {
114
107
  * already scoped to a participant, so nobody can answer another participant's
115
108
  * approval through it, but "this user may approve THIS action" is a question
116
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.
117
116
  */
118
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,68 +129,138 @@ export class AgentRuntime {
122
129
  text = response.text;
123
130
  finishReason = response.finishReason;
124
131
  const toolCalls = response.toolCalls ?? [];
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
+ }));
125
137
  // The next step's request is built from this row, so it keeps what a
126
- // provider needs to be sent back: each call's arguments (a tool_use without
127
- // its input is refused) and the provider state for the turn (G-58).
128
- await thread.record([
129
- {
130
- type: 'assistant',
131
- content: response.text,
132
- tool_calls: toolCalls.map((call) => ({ id: call.id, name: call.name, arguments: call.arguments })),
133
- additional_content: { ...(response.additionalContent ?? {}) },
134
- },
135
- ], runId);
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);
136
141
  if (toolCalls.length === 0) {
137
142
  return await this.#finish(session, runId, called, finishReason, text, run, null);
138
143
  }
139
- const pending = await this.#pendingApprovals(session, mode, toolCalls);
140
- if (pending.length > 0) {
141
- // FAILS CLOSED. The run stops here and the request is already in the
142
- // thread, so a different process can pick it up after a human answers.
143
- 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.
144
157
  return {
145
158
  runId,
146
159
  text,
147
160
  steps: run.ledger.steps,
148
161
  toolCalls: called,
149
162
  finishReason: 'awaiting_approval',
150
- 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
+ })),
151
169
  stoppedBecause: null,
152
170
  };
153
171
  }
154
- for (const call of toolCalls) {
155
- called.push(call.name);
156
- await thread.record([await this.#invoke(offered, call)], runId);
157
- }
158
172
  }
159
173
  }
160
174
  /**
161
- * 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:
162
180
  *
163
- * An approval already answered in the thread is NOT asked again — that is the
164
- * whole point of recording it durably. An answered-and-denied approval is
165
- * 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.
166
223
  */
167
- async #pendingApprovals(session, mode, toolCalls) {
168
- const gated = toolCalls.filter((call) => mode.needsApproval(call.name));
169
- if (gated.length === 0)
170
- return [];
171
- const answered = await this.#answeredApprovals(session);
172
- return gated
173
- .filter((call) => !answered.has(call.id))
174
- .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 };
175
241
  }
176
- async #answeredApprovals(session) {
177
- const answered = new Map();
178
- for (const entry of await session.thread().messages()) {
179
- if (entry.message.type !== 'tool_approval_response')
180
- continue;
181
- const id = entry.message.approval_id;
182
- const approved = entry.message.approved;
183
- if (typeof id === 'string')
184
- 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;
185
263
  }
186
- return answered;
187
264
  }
188
265
  async #invoke(offered, call) {
189
266
  const tool = offered.find((candidate) => candidate.name === call.name);
@@ -192,12 +269,7 @@ export class AgentRuntime {
192
269
  }
193
270
  try {
194
271
  const result = await tool.handle(call.arguments);
195
- return {
196
- type: 'tool_result',
197
- tool_call_id: call.id,
198
- name: call.name,
199
- result: typeof result === 'string' ? result : JSON.stringify(result ?? null),
200
- };
272
+ return toolResultEntry(call, typeof result === 'string' ? result : JSON.stringify(result ?? null));
201
273
  }
202
274
  catch (error) {
203
275
  // A failed tool is a RESULT, not a crashed run: the model can often
@@ -205,13 +277,7 @@ export class AgentRuntime {
205
277
  // call is different and is left to propagate — see `authorizedTool`.
206
278
  if (error instanceof HarnessError && error.code === 'call_not_authorized')
207
279
  throw error;
208
- return {
209
- type: 'tool_result',
210
- tool_call_id: call.id,
211
- name: call.name,
212
- result: `The tool failed: ${error instanceof Error ? error.message : String(error)}`,
213
- failed: true,
214
- };
280
+ return toolResultEntry(call, `The tool failed: ${error instanceof Error ? error.message : String(error)}`);
215
281
  }
216
282
  }
217
283
  async #finish(session, runId, called, finishReason, text, run, stoppedBecause) {
@@ -245,15 +311,14 @@ export class AgentRuntime {
245
311
  * already scoped to a participant, so nobody can answer another participant's
246
312
  * approval through it, but "this user may approve THIS action" is a question
247
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.
248
320
  */
249
321
  export async function recordApproval(session, approvalId, approved, reason = null) {
250
322
  const run = await session.run();
251
- await session.thread().record([
252
- {
253
- type: 'tool_approval_response',
254
- approval_id: approvalId,
255
- approved,
256
- reason,
257
- },
258
- ], run?.id ?? null);
323
+ await session.thread().record([toolResultRow([], [{ approval_id: approvalId, approved, reason }])], run?.id ?? null);
259
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.3.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,