@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 +46 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/runtime.d.ts +18 -19
- package/dist/runtime.js +145 -80
- package/dist/thread-rows.d.ts +71 -0
- package/dist/thread-rows.js +135 -0
- package/package.json +1 -1
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
|
-
|
|
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,
|
|
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
|
|
127
|
-
//
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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:
|
|
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
|
-
*
|
|
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
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
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 #
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
+
}
|