@particle-academy/prism-harness 0.0.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/LICENSE +21 -0
- package/README.md +179 -0
- package/dist/doctor.d.ts +35 -0
- package/dist/doctor.js +110 -0
- package/dist/errors.d.ts +199 -0
- package/dist/errors.js +201 -0
- package/dist/events.d.ts +67 -0
- package/dist/events.js +40 -0
- package/dist/harness.d.ts +51 -0
- package/dist/harness.js +70 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +20 -0
- package/dist/json.d.ts +15 -0
- package/dist/json.js +15 -0
- package/dist/modes.d.ts +63 -0
- package/dist/modes.js +109 -0
- package/dist/runtime.d.ts +104 -0
- package/dist/runtime.js +244 -0
- package/dist/session.d.ts +114 -0
- package/dist/session.js +198 -0
- package/dist/skills.d.ts +37 -0
- package/dist/skills.js +73 -0
- package/dist/store-task-source.d.ts +169 -0
- package/dist/store-task-source.js +513 -0
- package/dist/stores/file-session-store.d.ts +34 -0
- package/dist/stores/file-session-store.js +284 -0
- package/dist/stores/memory-session-store.d.ts +22 -0
- package/dist/stores/memory-session-store.js +97 -0
- package/dist/stores/session-store.d.ts +65 -0
- package/dist/stores/session-store.js +3 -0
- package/dist/stores/store-manager.d.ts +32 -0
- package/dist/stores/store-manager.js +57 -0
- package/dist/subagent-runner.d.ts +45 -0
- package/dist/subagent-runner.js +89 -0
- package/dist/subagents.d.ts +182 -0
- package/dist/subagents.js +286 -0
- package/dist/task-tools.d.ts +55 -0
- package/dist/task-tools.js +136 -0
- package/dist/tasks.d.ts +257 -0
- package/dist/tasks.js +162 -0
- package/dist/thread.d.ts +49 -0
- package/dist/thread.js +88 -0
- package/dist/tools.d.ts +89 -0
- package/dist/tools.js +148 -0
- package/package.json +34 -0
package/dist/modes.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { HarnessError } from './errors.js';
|
|
2
|
+
import { subagentFromConfig } from './subagents.js';
|
|
3
|
+
/**
|
|
4
|
+
* What a mode lets a run do.
|
|
5
|
+
*
|
|
6
|
+
* A mode is the unit of authority here: the tools it may reach, the skills it
|
|
7
|
+
* loads, how many steps it gets, which nested agents it may call, and which of
|
|
8
|
+
* its tools need a human first.
|
|
9
|
+
*/
|
|
10
|
+
export class AgentMode {
|
|
11
|
+
name;
|
|
12
|
+
systemPrompt;
|
|
13
|
+
tools;
|
|
14
|
+
skills;
|
|
15
|
+
maxSteps;
|
|
16
|
+
subagents;
|
|
17
|
+
requiresApproval;
|
|
18
|
+
constructor(name, systemPrompt, tools, skills, maxSteps,
|
|
19
|
+
/** Nested agents this mode may call, by name. */
|
|
20
|
+
subagents = {},
|
|
21
|
+
/** Tools that must not run until a human says so. */
|
|
22
|
+
requiresApproval = []) {
|
|
23
|
+
this.name = name;
|
|
24
|
+
this.systemPrompt = systemPrompt;
|
|
25
|
+
this.tools = tools;
|
|
26
|
+
this.skills = skills;
|
|
27
|
+
this.maxSteps = maxSteps;
|
|
28
|
+
this.subagents = subagents;
|
|
29
|
+
this.requiresApproval = requiresApproval;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Whether a named tool needs a human before it runs IN THIS MODE.
|
|
33
|
+
*
|
|
34
|
+
* Declared per mode rather than on the tool, because the same tool is not
|
|
35
|
+
* equally consequential everywhere: `execute_op` against a scratch project is
|
|
36
|
+
* routine and against production is not, and the tool cannot tell which it is
|
|
37
|
+
* in. `'*'` gates every tool the mode offers.
|
|
38
|
+
*/
|
|
39
|
+
needsApproval(tool) {
|
|
40
|
+
return this.requiresApproval.includes('*') || this.requiresApproval.includes(tool);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export class ModeRegistry {
|
|
44
|
+
config;
|
|
45
|
+
constructor(config = {}) {
|
|
46
|
+
this.config = config;
|
|
47
|
+
}
|
|
48
|
+
default() {
|
|
49
|
+
const configured = this.config.default;
|
|
50
|
+
return typeof configured === 'string' && configured !== '' ? configured : 'chat';
|
|
51
|
+
}
|
|
52
|
+
/** Every mode name this application has configured. */
|
|
53
|
+
names() {
|
|
54
|
+
return Object.keys(this.config.modes ?? {});
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Every mode, RESOLVED.
|
|
58
|
+
*
|
|
59
|
+
* Resolving them all is the point: `resolve()` validates as it goes, so a
|
|
60
|
+
* mode nobody has entered yet keeps its misconfiguration until the day
|
|
61
|
+
* somebody switches to it. This is what the doctor uses to find that on a
|
|
62
|
+
* Tuesday rather than in front of a user.
|
|
63
|
+
*/
|
|
64
|
+
all() {
|
|
65
|
+
return Object.fromEntries(this.names().map((name) => [name, this.resolve(name)]));
|
|
66
|
+
}
|
|
67
|
+
resolve(name) {
|
|
68
|
+
const wanted = name ?? this.default();
|
|
69
|
+
const mode = this.config.modes?.[wanted];
|
|
70
|
+
if (mode === undefined) {
|
|
71
|
+
throw HarnessError.modeNotConfigured(wanted);
|
|
72
|
+
}
|
|
73
|
+
const prompt = mode.system_prompt ?? '';
|
|
74
|
+
const maxSteps = mode.max_steps ?? 8;
|
|
75
|
+
if (typeof prompt !== 'string' || !Number.isInteger(maxSteps) || maxSteps < 1) {
|
|
76
|
+
throw HarnessError.modeMalformed(wanted, 'system_prompt must be a string and max_steps a positive integer');
|
|
77
|
+
}
|
|
78
|
+
return new AgentMode(wanted, prompt, strings(mode.tools), strings(mode.skills), maxSteps, this.#subagentsFor(wanted, mode), strings(mode.requires_approval));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The nested agents a mode may call.
|
|
82
|
+
*
|
|
83
|
+
* DECLARED PER MODE, which is the point: a subagent is authority, and
|
|
84
|
+
* authority a run inherits by being nested is authority nobody granted. A
|
|
85
|
+
* mode that names no subagents cannot spawn one.
|
|
86
|
+
*
|
|
87
|
+
* A subagent's own mode must EXIST, and it is checked here rather than at
|
|
88
|
+
* call time so a typo surfaces when the parent mode is loaded instead of
|
|
89
|
+
* halfway through a run that has already spent budget.
|
|
90
|
+
*/
|
|
91
|
+
#subagentsFor(name, mode) {
|
|
92
|
+
const declared = mode.subagents ?? {};
|
|
93
|
+
const subagents = {};
|
|
94
|
+
for (const [key, config] of Object.entries(declared)) {
|
|
95
|
+
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
|
|
96
|
+
throw HarnessError.modeMalformed(name, `subagent [${key}] is not an object`);
|
|
97
|
+
}
|
|
98
|
+
const subagent = subagentFromConfig(key, config);
|
|
99
|
+
if (this.config.modes?.[subagent.mode] === undefined) {
|
|
100
|
+
throw HarnessError.modeMalformed(name, `it declares subagent [${key}], whose mode [${subagent.mode}] is not configured`);
|
|
101
|
+
}
|
|
102
|
+
subagents[key] = subagent;
|
|
103
|
+
}
|
|
104
|
+
return subagents;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function strings(value) {
|
|
108
|
+
return (value ?? []).filter((entry) => typeof entry === 'string');
|
|
109
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { JsonObject } from './json.js';
|
|
2
|
+
import type { HarnessEvents } from './events.js';
|
|
3
|
+
import type { ModeRegistry } from './modes.js';
|
|
4
|
+
import type { Session } from './session.js';
|
|
5
|
+
import { RunContext } from './subagents.js';
|
|
6
|
+
import type { HarnessTool, ToolAuthorizer, ToolRegistry } from './tools.js';
|
|
7
|
+
/**
|
|
8
|
+
* What the runtime needs from a model, and NOTHING MORE.
|
|
9
|
+
*
|
|
10
|
+
* An INTERFACE rather than a dependency on `prism-ts`. The loop below — steps,
|
|
11
|
+
* budgets, approvals, thread recording, events — is the part worth porting, and
|
|
12
|
+
* none of it needs to know how a request reaches a provider. Keeping the seam
|
|
13
|
+
* here also means this package stays at zero dependencies and a consumer can
|
|
14
|
+
* drive it with `prism-ts`, with their own client, or with a fake in a test.
|
|
15
|
+
*
|
|
16
|
+
* The reference couples these because Prism is already a dependency there.
|
|
17
|
+
*/
|
|
18
|
+
export interface LlmRequest {
|
|
19
|
+
systemPrompt: string;
|
|
20
|
+
/** The conversation so far, serialized — oldest first. */
|
|
21
|
+
messages: readonly JsonObject[];
|
|
22
|
+
tools: readonly HarnessTool[];
|
|
23
|
+
provider: string;
|
|
24
|
+
model: string;
|
|
25
|
+
}
|
|
26
|
+
export interface LlmToolCall {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
arguments: JsonObject;
|
|
30
|
+
}
|
|
31
|
+
export interface LlmResponse {
|
|
32
|
+
text: string;
|
|
33
|
+
toolCalls?: readonly LlmToolCall[];
|
|
34
|
+
/** `stop`, `tool_calls`, `length`, … — the provider's own reason, passed through. */
|
|
35
|
+
finishReason: string;
|
|
36
|
+
/** Null when the provider does not report one. NOT zero — see `RunLedger.recordCost`. */
|
|
37
|
+
costUsd?: number | null;
|
|
38
|
+
}
|
|
39
|
+
export type LlmClient = (request: LlmRequest) => Promise<LlmResponse>;
|
|
40
|
+
export interface AgentResponse {
|
|
41
|
+
runId: string;
|
|
42
|
+
text: string;
|
|
43
|
+
steps: number;
|
|
44
|
+
/** NAMES only, in call order. */
|
|
45
|
+
toolCalls: readonly string[];
|
|
46
|
+
finishReason: string;
|
|
47
|
+
/** Set when the run stopped because a tool needs a human. */
|
|
48
|
+
pendingApprovals: readonly PendingApproval[];
|
|
49
|
+
/** Set when the run stopped because the tree ran out of budget, or was cancelled. */
|
|
50
|
+
stoppedBecause: string | null;
|
|
51
|
+
}
|
|
52
|
+
export interface PendingApproval {
|
|
53
|
+
id: string;
|
|
54
|
+
tool: string;
|
|
55
|
+
arguments: JsonObject;
|
|
56
|
+
}
|
|
57
|
+
export interface AgentRuntimeOptions {
|
|
58
|
+
client: LlmClient;
|
|
59
|
+
modes: ModeRegistry;
|
|
60
|
+
tools: ToolRegistry;
|
|
61
|
+
authorizer?: ToolAuthorizer;
|
|
62
|
+
events?: HarnessEvents;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The loop: prompt in, turns out, everything recorded.
|
|
66
|
+
*
|
|
67
|
+
* Three properties matter more than the mechanics.
|
|
68
|
+
*
|
|
69
|
+
* **Every step is checked against the budget BEFORE it is taken**, not after.
|
|
70
|
+
* Checking afterwards means the step that broke the limit has already been
|
|
71
|
+
* paid for, which makes a budget a report rather than a control.
|
|
72
|
+
*
|
|
73
|
+
* **An approval stops the run and is written to the THREAD**, not held in
|
|
74
|
+
* memory. That is what makes it survive: the approval a person grants this
|
|
75
|
+
* morning is a durable row, so the worker that resumes tonight — a different
|
|
76
|
+
* process, possibly after a deploy — reads the same answer.
|
|
77
|
+
*
|
|
78
|
+
* **A tool that needs approval and has none DOES NOT RUN.** Failing closed is
|
|
79
|
+
* the only safe direction: an unanswered approval that executed anyway is
|
|
80
|
+
* exactly the outcome the whole mechanism exists to prevent.
|
|
81
|
+
*/
|
|
82
|
+
export declare class AgentRuntime {
|
|
83
|
+
#private;
|
|
84
|
+
constructor(options: AgentRuntimeOptions);
|
|
85
|
+
/**
|
|
86
|
+
* Run a turn.
|
|
87
|
+
*
|
|
88
|
+
* An EMPTY prompt is meaningful and not an error: it is how a run resumes
|
|
89
|
+
* after an approval, because the conversation already contains the request,
|
|
90
|
+
* the decision, and everything before them. A new prompt there would be a
|
|
91
|
+
* second instruction competing with the one the tool call came from.
|
|
92
|
+
*/
|
|
93
|
+
send(session: Session, prompt: string, toolNames?: readonly string[], context?: RunContext): Promise<AgentResponse>;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Answer a pending approval, durably.
|
|
97
|
+
*
|
|
98
|
+
* The decision is RECORDED IN THE THREAD, not held anywhere else. Who may
|
|
99
|
+
* approve is the APPLICATION's decision, not this package's: the session is
|
|
100
|
+
* already scoped to a participant, so nobody can answer another participant's
|
|
101
|
+
* approval through it, but "this user may approve THIS action" is a question
|
|
102
|
+
* only the host can answer. Authorize before calling.
|
|
103
|
+
*/
|
|
104
|
+
export declare function recordApproval(session: Session, approvalId: string, approved: boolean, reason?: string | null): Promise<void>;
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { HarnessError } from './errors.js';
|
|
3
|
+
import { RunBudget, RunContext } from './subagents.js';
|
|
4
|
+
/**
|
|
5
|
+
* The loop: prompt in, turns out, everything recorded.
|
|
6
|
+
*
|
|
7
|
+
* Three properties matter more than the mechanics.
|
|
8
|
+
*
|
|
9
|
+
* **Every step is checked against the budget BEFORE it is taken**, not after.
|
|
10
|
+
* Checking afterwards means the step that broke the limit has already been
|
|
11
|
+
* paid for, which makes a budget a report rather than a control.
|
|
12
|
+
*
|
|
13
|
+
* **An approval stops the run and is written to the THREAD**, not held in
|
|
14
|
+
* memory. That is what makes it survive: the approval a person grants this
|
|
15
|
+
* morning is a durable row, so the worker that resumes tonight — a different
|
|
16
|
+
* process, possibly after a deploy — reads the same answer.
|
|
17
|
+
*
|
|
18
|
+
* **A tool that needs approval and has none DOES NOT RUN.** Failing closed is
|
|
19
|
+
* the only safe direction: an unanswered approval that executed anyway is
|
|
20
|
+
* exactly the outcome the whole mechanism exists to prevent.
|
|
21
|
+
*/
|
|
22
|
+
export class AgentRuntime {
|
|
23
|
+
#client;
|
|
24
|
+
#modes;
|
|
25
|
+
#tools;
|
|
26
|
+
#authorizer;
|
|
27
|
+
#events;
|
|
28
|
+
constructor(options) {
|
|
29
|
+
this.#client = options.client;
|
|
30
|
+
this.#modes = options.modes;
|
|
31
|
+
this.#tools = options.tools;
|
|
32
|
+
this.#authorizer = options.authorizer;
|
|
33
|
+
this.#events = options.events;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Run a turn.
|
|
37
|
+
*
|
|
38
|
+
* An EMPTY prompt is meaningful and not an error: it is how a run resumes
|
|
39
|
+
* after an approval, because the conversation already contains the request,
|
|
40
|
+
* the decision, and everything before them. A new prompt there would be a
|
|
41
|
+
* second instruction competing with the one the tool call came from.
|
|
42
|
+
*/
|
|
43
|
+
async send(session, prompt, toolNames, context) {
|
|
44
|
+
const mode = this.#modes.resolve(await session.mode());
|
|
45
|
+
const provider = (await session.provider()) ?? 'unknown';
|
|
46
|
+
const model = (await session.model()) ?? 'unknown';
|
|
47
|
+
const runId = randomUUID();
|
|
48
|
+
const run = context ?? RunContext.root(runId, new RunBudget(mode.maxSteps));
|
|
49
|
+
const thread = session.thread();
|
|
50
|
+
if (run.tooDeep()) {
|
|
51
|
+
throw HarnessError.runNotPermitted(`This run is nested ${run.depth} deep, at or past the ceiling. Nobody debugs a tree that deep, ` +
|
|
52
|
+
'and a configuration that produced one is a mistake worth reporting rather than executing.');
|
|
53
|
+
}
|
|
54
|
+
await session.beginRun(runId, mode.name, provider, model);
|
|
55
|
+
this.#events?.emit({
|
|
56
|
+
type: 'run.started',
|
|
57
|
+
runId,
|
|
58
|
+
sessionKey: session.key(),
|
|
59
|
+
mode: mode.name,
|
|
60
|
+
provider,
|
|
61
|
+
model,
|
|
62
|
+
rootRunId: run.rootRunId,
|
|
63
|
+
depth: run.depth,
|
|
64
|
+
at: new Date().toISOString(),
|
|
65
|
+
});
|
|
66
|
+
if (prompt !== '') {
|
|
67
|
+
await thread.record([{ type: 'user', content: prompt }], runId);
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
return await this.#loop(session, mode, run, runId, provider, model, toolNames);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
const failure = error instanceof Error ? error.message : String(error);
|
|
74
|
+
await session.failRun(runId, failure);
|
|
75
|
+
this.#events?.emit({
|
|
76
|
+
type: 'run.failed',
|
|
77
|
+
runId,
|
|
78
|
+
sessionKey: session.key(),
|
|
79
|
+
failure,
|
|
80
|
+
steps: run.ledger.steps,
|
|
81
|
+
at: new Date().toISOString(),
|
|
82
|
+
});
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async #loop(session, mode, run, runId, provider, model, toolNames) {
|
|
87
|
+
const thread = session.thread();
|
|
88
|
+
const resolved = await this.#tools.resolve(toolNames ?? mode.tools, session);
|
|
89
|
+
const offered = this.#authorizer
|
|
90
|
+
? await this.#authorizer.allowed(session, resolved)
|
|
91
|
+
: [...resolved.values()];
|
|
92
|
+
const called = [];
|
|
93
|
+
let text = '';
|
|
94
|
+
let finishReason = 'stop';
|
|
95
|
+
for (;;) {
|
|
96
|
+
// BEFORE the step, never after. Checking afterwards means the step that
|
|
97
|
+
// broke the limit has already been paid for.
|
|
98
|
+
const exhausted = run.ledger.exhaustion(run.budget);
|
|
99
|
+
if (exhausted !== null) {
|
|
100
|
+
return await this.#finish(session, runId, called, 'budget_exhausted', text, run, exhausted);
|
|
101
|
+
}
|
|
102
|
+
const response = await this.#client({
|
|
103
|
+
systemPrompt: mode.systemPrompt,
|
|
104
|
+
messages: (await thread.messages()).map((entry) => entry.message),
|
|
105
|
+
tools: offered,
|
|
106
|
+
provider,
|
|
107
|
+
model,
|
|
108
|
+
});
|
|
109
|
+
run.ledger.recordSteps(1);
|
|
110
|
+
run.ledger.recordCost(response.costUsd ?? null);
|
|
111
|
+
text = response.text;
|
|
112
|
+
finishReason = response.finishReason;
|
|
113
|
+
const toolCalls = response.toolCalls ?? [];
|
|
114
|
+
await thread.record([
|
|
115
|
+
{
|
|
116
|
+
type: 'assistant',
|
|
117
|
+
content: response.text,
|
|
118
|
+
tool_calls: toolCalls.map((call) => ({ id: call.id, name: call.name })),
|
|
119
|
+
},
|
|
120
|
+
], runId);
|
|
121
|
+
if (toolCalls.length === 0) {
|
|
122
|
+
return await this.#finish(session, runId, called, finishReason, text, run, null);
|
|
123
|
+
}
|
|
124
|
+
const pending = await this.#pendingApprovals(session, mode, toolCalls);
|
|
125
|
+
if (pending.length > 0) {
|
|
126
|
+
// FAILS CLOSED. The run stops here and the request is already in the
|
|
127
|
+
// thread, so a different process can pick it up after a human answers.
|
|
128
|
+
await thread.record([{ type: 'tool_approval_request', approvals: pending }], runId);
|
|
129
|
+
return {
|
|
130
|
+
runId,
|
|
131
|
+
text,
|
|
132
|
+
steps: run.ledger.steps,
|
|
133
|
+
toolCalls: called,
|
|
134
|
+
finishReason: 'awaiting_approval',
|
|
135
|
+
pendingApprovals: pending,
|
|
136
|
+
stoppedBecause: null,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
for (const call of toolCalls) {
|
|
140
|
+
called.push(call.name);
|
|
141
|
+
await thread.record([await this.#invoke(offered, call)], runId);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Which of these calls needs a human, and has not had one.
|
|
147
|
+
*
|
|
148
|
+
* An approval already answered in the thread is NOT asked again — that is the
|
|
149
|
+
* whole point of recording it durably. An answered-and-denied approval is
|
|
150
|
+
* also not asked again; it is simply not executed.
|
|
151
|
+
*/
|
|
152
|
+
async #pendingApprovals(session, mode, toolCalls) {
|
|
153
|
+
const gated = toolCalls.filter((call) => mode.needsApproval(call.name));
|
|
154
|
+
if (gated.length === 0)
|
|
155
|
+
return [];
|
|
156
|
+
const answered = await this.#answeredApprovals(session);
|
|
157
|
+
return gated
|
|
158
|
+
.filter((call) => !answered.has(call.id))
|
|
159
|
+
.map((call) => ({ id: call.id, tool: call.name, arguments: call.arguments }));
|
|
160
|
+
}
|
|
161
|
+
async #answeredApprovals(session) {
|
|
162
|
+
const answered = new Map();
|
|
163
|
+
for (const entry of await session.thread().messages()) {
|
|
164
|
+
if (entry.message.type !== 'tool_approval_response')
|
|
165
|
+
continue;
|
|
166
|
+
const id = entry.message.approval_id;
|
|
167
|
+
const approved = entry.message.approved;
|
|
168
|
+
if (typeof id === 'string')
|
|
169
|
+
answered.set(id, approved === true);
|
|
170
|
+
}
|
|
171
|
+
return answered;
|
|
172
|
+
}
|
|
173
|
+
async #invoke(offered, call) {
|
|
174
|
+
const tool = offered.find((candidate) => candidate.name === call.name);
|
|
175
|
+
if (tool === undefined) {
|
|
176
|
+
throw HarnessError.toolNotAvailable(call.name, offered.map((candidate) => candidate.name));
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const result = await tool.handle(call.arguments);
|
|
180
|
+
return {
|
|
181
|
+
type: 'tool_result',
|
|
182
|
+
tool_call_id: call.id,
|
|
183
|
+
name: call.name,
|
|
184
|
+
result: typeof result === 'string' ? result : JSON.stringify(result ?? null),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
// A failed tool is a RESULT, not a crashed run: the model can often
|
|
189
|
+
// recover, and losing the whole turn to one bad call is worse. A refused
|
|
190
|
+
// call is different and is left to propagate — see `authorizedTool`.
|
|
191
|
+
if (error instanceof HarnessError && error.code === 'call_not_authorized')
|
|
192
|
+
throw error;
|
|
193
|
+
return {
|
|
194
|
+
type: 'tool_result',
|
|
195
|
+
tool_call_id: call.id,
|
|
196
|
+
name: call.name,
|
|
197
|
+
result: `The tool failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
198
|
+
failed: true,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async #finish(session, runId, called, finishReason, text, run, stoppedBecause) {
|
|
203
|
+
await session.completeRun(runId, finishReason, called);
|
|
204
|
+
this.#events?.emit({
|
|
205
|
+
type: 'run.finished',
|
|
206
|
+
runId,
|
|
207
|
+
sessionKey: session.key(),
|
|
208
|
+
finishReason,
|
|
209
|
+
toolCalls: called,
|
|
210
|
+
steps: run.ledger.steps,
|
|
211
|
+
costUsd: run.ledger.unmeteredRuns > 0 ? null : run.ledger.costUsd,
|
|
212
|
+
at: new Date().toISOString(),
|
|
213
|
+
});
|
|
214
|
+
return {
|
|
215
|
+
runId,
|
|
216
|
+
text,
|
|
217
|
+
steps: run.ledger.steps,
|
|
218
|
+
toolCalls: called,
|
|
219
|
+
finishReason,
|
|
220
|
+
pendingApprovals: [],
|
|
221
|
+
stoppedBecause,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Answer a pending approval, durably.
|
|
227
|
+
*
|
|
228
|
+
* The decision is RECORDED IN THE THREAD, not held anywhere else. Who may
|
|
229
|
+
* approve is the APPLICATION's decision, not this package's: the session is
|
|
230
|
+
* already scoped to a participant, so nobody can answer another participant's
|
|
231
|
+
* approval through it, but "this user may approve THIS action" is a question
|
|
232
|
+
* only the host can answer. Authorize before calling.
|
|
233
|
+
*/
|
|
234
|
+
export async function recordApproval(session, approvalId, approved, reason = null) {
|
|
235
|
+
const run = await session.run();
|
|
236
|
+
await session.thread().record([
|
|
237
|
+
{
|
|
238
|
+
type: 'tool_approval_response',
|
|
239
|
+
approval_id: approvalId,
|
|
240
|
+
approved,
|
|
241
|
+
reason,
|
|
242
|
+
},
|
|
243
|
+
], run?.id ?? null);
|
|
244
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { JsonObject } from './json.js';
|
|
2
|
+
import type { SessionStore } from './stores/session-store.js';
|
|
3
|
+
import { StoreTaskSource } from './store-task-source.js';
|
|
4
|
+
import type { StoreTaskSourceOptions } from './store-task-source.js';
|
|
5
|
+
import { Thread } from './thread.js';
|
|
6
|
+
/**
|
|
7
|
+
* Who a session belongs to.
|
|
8
|
+
*
|
|
9
|
+
* A TYPE and an ID, not just an id: one application holds users and teams and
|
|
10
|
+
* bots, and `7` means a different participant in each. The reference gets the
|
|
11
|
+
* same pair from Eloquent's morph class and primary key.
|
|
12
|
+
*/
|
|
13
|
+
export interface Participant {
|
|
14
|
+
type: string;
|
|
15
|
+
id: string | number;
|
|
16
|
+
}
|
|
17
|
+
export interface RunState extends JsonObject {
|
|
18
|
+
id: string;
|
|
19
|
+
status: 'running' | 'completed' | 'failed';
|
|
20
|
+
mode?: string;
|
|
21
|
+
provider?: string;
|
|
22
|
+
model?: string;
|
|
23
|
+
started_at?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface SessionOptions {
|
|
26
|
+
participant: Participant;
|
|
27
|
+
scope: string;
|
|
28
|
+
ephemeral: SessionStore;
|
|
29
|
+
durable: SessionStore;
|
|
30
|
+
/** How long ephemeral state lives. Null keeps it until forgotten. */
|
|
31
|
+
ttlSeconds?: number | null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* One participant's live runtime, reconstructed per turn.
|
|
35
|
+
*
|
|
36
|
+
* RESOLVED, NEVER HELD. Nothing survives in memory between turns, so a fresh
|
|
37
|
+
* worker resolving the same address has to see the same active mode, the same
|
|
38
|
+
* model and the same conversation as whatever set them. Everything here reads
|
|
39
|
+
* through a store for that reason, rather than being a property that happens to
|
|
40
|
+
* be populated.
|
|
41
|
+
*
|
|
42
|
+
* State is split deliberately:
|
|
43
|
+
*
|
|
44
|
+
* - mode, model, provider and run bookkeeping are EPHEMERAL. Lose them and the
|
|
45
|
+
* next turn falls back to a default, which is a shrug.
|
|
46
|
+
* - the thread and stored capabilities are DURABLE.
|
|
47
|
+
*/
|
|
48
|
+
export declare class Session {
|
|
49
|
+
#private;
|
|
50
|
+
readonly scope: string;
|
|
51
|
+
readonly participant: Participant;
|
|
52
|
+
constructor(options: SessionOptions);
|
|
53
|
+
/**
|
|
54
|
+
* The address this session is resolved by.
|
|
55
|
+
*
|
|
56
|
+
* Participant AND scope, because one participant holds several unrelated
|
|
57
|
+
* conversations at once and they must not collide.
|
|
58
|
+
*
|
|
59
|
+
* The type is HASHED rather than interpolated, and with sha1 truncated to 12
|
|
60
|
+
* characters — byte for byte what the PHP reference produces. A class name
|
|
61
|
+
* contains backslashes there, which make for awkward store keys and leak the
|
|
62
|
+
* application's namespace layout into something visible in tooling. Matching
|
|
63
|
+
* the reference exactly is what lets a PHP app and a TypeScript agent share
|
|
64
|
+
* ONE store and resolve the same session.
|
|
65
|
+
*/
|
|
66
|
+
key(): string;
|
|
67
|
+
mode(): Promise<string | null>;
|
|
68
|
+
usingMode(mode: string): Promise<this>;
|
|
69
|
+
model(): Promise<string | null>;
|
|
70
|
+
usingModel(model: string): Promise<this>;
|
|
71
|
+
provider(): Promise<string | null>;
|
|
72
|
+
usingProvider(provider: string): Promise<this>;
|
|
73
|
+
state(): Promise<JsonObject>;
|
|
74
|
+
/** Drop the ephemeral half. THE CONVERSATION IS UNTOUCHED. */
|
|
75
|
+
forget(): Promise<this>;
|
|
76
|
+
/** The stored conversation this session is bound to. */
|
|
77
|
+
thread(): Thread;
|
|
78
|
+
/**
|
|
79
|
+
* The agent task list this session is bound to.
|
|
80
|
+
*
|
|
81
|
+
* The DURABLE half, alongside the thread, for the same reason: a
|
|
82
|
+
* half-finished task list that vanishes on a deploy is indistinguishable from
|
|
83
|
+
* a finished one. A volatile durable slot has already been refused by the
|
|
84
|
+
* store manager before this can be reached, and `StoreTaskSource` refuses one
|
|
85
|
+
* again on its own -- the second check is what makes a source built directly
|
|
86
|
+
* against a store as safe as one reached through here.
|
|
87
|
+
*/
|
|
88
|
+
tasks(options?: StoreTaskSourceOptions): StoreTaskSource;
|
|
89
|
+
capability(name: string): Promise<JsonObject | null>;
|
|
90
|
+
usingCapability(name: string, state: JsonObject): Promise<this>;
|
|
91
|
+
forgetCapability(name: string): Promise<this>;
|
|
92
|
+
run(): Promise<RunState | null>;
|
|
93
|
+
beginRun(id: string, mode: string, provider: string, model: string): Promise<this>;
|
|
94
|
+
/**
|
|
95
|
+
* @param toolCalls the NAMES of the tools this run invoked, in order.
|
|
96
|
+
*
|
|
97
|
+
* NAMES ONLY, and that boundary is deliberate. "Which tools did this run
|
|
98
|
+
* reach for" is what an operator needs to audit a guardrail, and a tool name
|
|
99
|
+
* is not PII. ARGUMENTS are — `prism-opentelemetry` already carries them
|
|
100
|
+
* behind an opt-in capture gate with a length cap, and recording them a
|
|
101
|
+
* second time here, ungated, would quietly undo that decision for everyone
|
|
102
|
+
* who installed both.
|
|
103
|
+
*/
|
|
104
|
+
completeRun(id: string, finishReason: string, toolCalls?: readonly string[]): Promise<this>;
|
|
105
|
+
failRun(id: string, failure: string): Promise<this>;
|
|
106
|
+
/**
|
|
107
|
+
* Run something that MUST NOT HAPPEN TWICE.
|
|
108
|
+
*
|
|
109
|
+
* Two workers can hold the same session at the same moment: a queued job
|
|
110
|
+
* finishing a run while the user sends another message is ordinary. Advance a
|
|
111
|
+
* run inside this, not outside it.
|
|
112
|
+
*/
|
|
113
|
+
lock<T>(callback: (session: this) => T | Promise<T>, ttlSeconds?: number, waitSeconds?: number): Promise<T>;
|
|
114
|
+
}
|