@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 +47 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/runtime.d.ts +26 -19
- package/dist/runtime.js +146 -77
- package/dist/thread-rows.d.ts +71 -0
- package/dist/thread-rows.js +135 -0
- package/package.json +1 -1
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: {
|
|
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
|
-
|
|
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,64 +129,138 @@ export class AgentRuntime {
|
|
|
122
129
|
text = response.text;
|
|
123
130
|
finishReason = response.finishReason;
|
|
124
131
|
const toolCalls = response.toolCalls ?? [];
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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:
|
|
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
|
-
*
|
|
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
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
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 #
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
+
}
|