@openshain/agent 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/NOTICE +4 -0
- package/dist/client.d.ts +24 -0
- package/dist/client.js +48 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +3 -3
- package/dist/session.d.ts +84 -16
- package/dist/session.js +622 -339
- package/package.json +7 -4
- package/src/client.ts +73 -0
- package/src/index.ts +5 -12
- package/src/session.ts +761 -397
- package/dist/loop.d.ts +0 -37
- package/dist/loop.js +0 -382
- package/src/loop.ts +0 -479
package/src/session.ts
CHANGED
|
@@ -1,93 +1,119 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AnyEvent,
|
|
3
|
+
ASK_USER_TOOL_NAME,
|
|
3
4
|
type AssistantPart,
|
|
4
5
|
buildProjection,
|
|
5
|
-
|
|
6
|
+
type Config,
|
|
6
7
|
type Event,
|
|
7
|
-
type
|
|
8
|
-
|
|
8
|
+
type EventPayloads,
|
|
9
|
+
type EventType,
|
|
10
|
+
eventToFile,
|
|
9
11
|
isTerminal,
|
|
10
12
|
type ModelProvider,
|
|
11
13
|
type ModelResponse,
|
|
12
|
-
|
|
13
|
-
type Runtime,
|
|
14
|
+
newEventId,
|
|
14
15
|
SESSION_WORK_TYPE,
|
|
15
16
|
type ToolContent,
|
|
16
17
|
type ToolDefinition,
|
|
17
18
|
type Work,
|
|
18
|
-
type WorkHandle,
|
|
19
19
|
type WorkId,
|
|
20
20
|
} from "@openshain/core";
|
|
21
|
-
import {
|
|
21
|
+
import { type ClientResult, jsonOf, type RuntimeClient } from "./client.ts";
|
|
22
22
|
import { pickAgentName } from "./names.ts";
|
|
23
23
|
|
|
24
|
-
/** How much one turn of
|
|
25
|
-
export const TURN_LIMITS = { modelCalls:
|
|
24
|
+
/** How much one turn of the conversation may do before it stops and the person is told. */
|
|
25
|
+
export const TURN_LIMITS = { modelCalls: 25, toolCalls: 40 } as const;
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
properties: {
|
|
36
|
-
objective: {
|
|
37
|
-
type: "string",
|
|
38
|
-
minLength: 1,
|
|
39
|
-
description: "The request, in the person's words.",
|
|
40
|
-
},
|
|
41
|
-
type: {
|
|
42
|
-
type: "string",
|
|
43
|
-
description: "A short label for the kind of work. Defaults to request.",
|
|
44
|
-
},
|
|
45
|
-
},
|
|
46
|
-
required: ["objective"],
|
|
47
|
-
additionalProperties: false,
|
|
48
|
-
},
|
|
49
|
-
effect: "mutate",
|
|
50
|
-
},
|
|
51
|
-
{
|
|
52
|
-
name: "work_list",
|
|
53
|
-
description: "The most recent works in this workspace, newest first, without sessions.",
|
|
54
|
-
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
55
|
-
effect: "observe",
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
name: "work_show",
|
|
59
|
-
description: "One work by id: its status, summary, artifacts, usage, and who has to act next.",
|
|
60
|
-
inputSchema: {
|
|
61
|
-
type: "object",
|
|
62
|
-
properties: { id: { type: "string", minLength: 1 } },
|
|
63
|
-
required: ["id"],
|
|
64
|
-
additionalProperties: false,
|
|
65
|
-
},
|
|
66
|
-
effect: "observe",
|
|
67
|
-
},
|
|
27
|
+
/** The tools of the runtime that the loop itself drives; the model never sees them. */
|
|
28
|
+
const LOOP_ONLY_TOOLS: ReadonlySet<string> = new Set([
|
|
29
|
+
"work_record",
|
|
30
|
+
"work_answer",
|
|
31
|
+
// Deciding is the person's, or a qualified reviewer's. A model that could call these would
|
|
32
|
+
// approve the very calls the policy held.
|
|
33
|
+
"approval_decide",
|
|
34
|
+
"review_decide",
|
|
68
35
|
]);
|
|
69
36
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
37
|
+
/**
|
|
38
|
+
* What the conversation adds to the profession's own instructions. Written as sections, and as
|
|
39
|
+
* what to do rather than what to avoid: models differ in how much they say after a tool call,
|
|
40
|
+
* so the screen's side of the contract is stated here instead of left to a model's default.
|
|
41
|
+
*/
|
|
42
|
+
const ROLE = [
|
|
43
|
+
"# 画面",
|
|
44
|
+
"あなたの返答は端末の画面に出る。人に見えるのは、あなたが書いた文と、Tool 呼び出しの名前と引数の 1 行だけ。Tool が返した中身と、work_complete に書いた summary は人には見えない。依頼の答えは返答に書く。",
|
|
45
|
+
"",
|
|
46
|
+
"# 返答の書き方",
|
|
47
|
+
"- 結果から書く。前置き(「承知しました」)と後置き(「ご不明な点があれば」)は書かない",
|
|
48
|
+
"- 依頼が終わったターンでは、何をしたか、答えになる数字(件数、金額、書いたファイルの場所)を書く。次にできることがあれば 1 行で添える",
|
|
49
|
+
"- 見出し、箇条書き、番号、太字、コードブロック、引用が使える。画面がそのまま書式として描く。表は書式にならないので、箇条書きにする",
|
|
50
|
+
"- 数字は Tool が返した値をそのまま書く",
|
|
51
|
+
"- 長さは依頼の大きさに合わせる。1 行で足りる依頼には 1 行で答える",
|
|
52
|
+
"",
|
|
53
|
+
"# 仕事の進め方",
|
|
54
|
+
"- あなたは受付の役でこの人と話す。作業が要るときは work_create で Work を作り(objective は人の言葉で書き、会話で分かった前提を添える)、その Work の中で Tool を呼び、work_complete の summary に記録用の要約を書いて閉じる。summary は記録に残すもの、返答は人に伝えるもの",
|
|
55
|
+
"- 会話の中では Tool を呼べない。ファイルの中身を読まないと答えられない質問も、Work を作って調べる",
|
|
56
|
+
"- /work resume で候補として示された Work は、人の依頼がその objective に沿うときだけ work_select で続ける。沿わなければ続けず、その旨を伝えて新しい Work を作るか work_list で探し直す",
|
|
57
|
+
"- 過去の作業は work_list と work_get で答える",
|
|
58
|
+
"",
|
|
59
|
+
"# 承認と資格者の判断",
|
|
60
|
+
"- 承認が要る呼び出しは止まる。人が決めるまで待ち、同じ呼び出しを繰り返さない",
|
|
61
|
+
"- 実行しないと決められた呼び出しは、理由を読んで別の案を出す。同じ入力で呼び直さない",
|
|
62
|
+
"- 承認と判断は人と資格者の仕事で、あなたの仕事ではない",
|
|
63
|
+
].join("\n");
|
|
76
64
|
|
|
77
65
|
export interface SessionOptions {
|
|
78
|
-
/**
|
|
79
|
-
model
|
|
66
|
+
/** The model the conversation runs on. The client owns it; the runtime never calls one. */
|
|
67
|
+
model: ModelProvider;
|
|
68
|
+
/** The workspace's configuration, for the prompt, the limits and the provider options. */
|
|
69
|
+
config: Pick<Config, "company" | "principal" | "profession" | "limits" | "model" | "debug">;
|
|
80
70
|
/** The name the agent goes by. Picked from the list, avoiding open sessions' names, when omitted. */
|
|
81
71
|
agentName?: string;
|
|
82
|
-
/** Called
|
|
83
|
-
onEvent?: (event: AnyEvent) => void | Promise<void>;
|
|
84
|
-
/** Called after every event a work started from this session records. A returned promise is awaited. */
|
|
85
|
-
onWorkEvent?: (workId: WorkId, event: AnyEvent) => void | Promise<void>;
|
|
72
|
+
/** Called for every event the session records or sees: the session's own and the works'. A returned promise is awaited. */
|
|
73
|
+
onEvent?: (workId: WorkId, event: AnyEvent) => void | Promise<void>;
|
|
86
74
|
/** Answers a question a work asks the person. Without it, the work waits for input. */
|
|
87
75
|
onInput?: (workId: WorkId, question: string) => Promise<string>;
|
|
76
|
+
/**
|
|
77
|
+
* Asks the person about a call the policy held, while the turn waits. Without it, the turn
|
|
78
|
+
* ends and the call stays held for `/approve` or for another client.
|
|
79
|
+
*/
|
|
80
|
+
onApproval?: (held: HeldApproval) => Promise<ApprovalAnswer>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type TurnStop =
|
|
84
|
+
| "turn_limit"
|
|
85
|
+
| "aborted"
|
|
86
|
+
| "max_tokens"
|
|
87
|
+
| "refusal"
|
|
88
|
+
| "model_error"
|
|
89
|
+
| "approval";
|
|
90
|
+
|
|
91
|
+
/** A tool call the policy holds until a person decides on it. */
|
|
92
|
+
export interface HeldApproval {
|
|
93
|
+
approvalId: string;
|
|
94
|
+
workId: WorkId;
|
|
95
|
+
name: string;
|
|
96
|
+
input: unknown;
|
|
97
|
+
/** The rule that held it: the unit a person can say yes to for the rest of the conversation. */
|
|
98
|
+
ruleId: string;
|
|
99
|
+
/** review when a qualified reviewer has to decide; a person cannot stand in for one. */
|
|
100
|
+
kind: "approval" | "review";
|
|
101
|
+
reviewer?: { role: string; name?: string };
|
|
88
102
|
}
|
|
89
103
|
|
|
90
|
-
|
|
104
|
+
/**
|
|
105
|
+
* What the person answered about a held call. `always` approves this one and every later call
|
|
106
|
+
* the same rule holds, for this conversation only: nothing is written to authority/, and every
|
|
107
|
+
* call is still recorded as requested and decided.
|
|
108
|
+
*/
|
|
109
|
+
export type ApprovalChoice = "approve" | "always" | "reject";
|
|
110
|
+
|
|
111
|
+
/** The person's answer: what they chose, and what they want the agent to know. */
|
|
112
|
+
export interface ApprovalAnswer {
|
|
113
|
+
choice: ApprovalChoice;
|
|
114
|
+
/** Why, in the person's words. Recorded with the decision and handed to the model. */
|
|
115
|
+
comment?: string;
|
|
116
|
+
}
|
|
91
117
|
|
|
92
118
|
export interface TurnResult {
|
|
93
119
|
/** What the model said to the person, possibly empty when the turn stopped early. */
|
|
@@ -95,6 +121,10 @@ export interface TurnResult {
|
|
|
95
121
|
/** Why the turn ended before the model replied, if it did. */
|
|
96
122
|
stopped?: TurnStop;
|
|
97
123
|
detail?: string;
|
|
124
|
+
/** The work the turn left open, when it stopped inside one. It can be continued with select. */
|
|
125
|
+
work?: WorkId;
|
|
126
|
+
/** The call held for approval, when the turn stopped for one. */
|
|
127
|
+
approval?: HeldApproval;
|
|
98
128
|
}
|
|
99
129
|
|
|
100
130
|
export interface Session {
|
|
@@ -103,381 +133,734 @@ export interface Session {
|
|
|
103
133
|
readonly agentName: string;
|
|
104
134
|
/** Records what the person said and runs the model until it replies or the turn stops. */
|
|
105
135
|
turn(text: string, options?: { signal?: AbortSignal }): Promise<TurnResult>;
|
|
106
|
-
/**
|
|
136
|
+
/** Names a stopped work as the candidate for the next request. The model decides whether to continue it. */
|
|
137
|
+
select(workId: WorkId): Promise<Work>;
|
|
138
|
+
/** Decides a held call as the person. approve runs it; either way the work becomes the candidate. */
|
|
139
|
+
decide(
|
|
140
|
+
approvalId: string,
|
|
141
|
+
decision: "approve" | "reject",
|
|
142
|
+
comment?: string,
|
|
143
|
+
): Promise<{ workId: WorkId; text: string }>;
|
|
144
|
+
/** Records what a qualified reviewer decided about a call held for review. */
|
|
145
|
+
review(input: {
|
|
146
|
+
approvalId: string;
|
|
147
|
+
decision: "approve" | "reject";
|
|
148
|
+
reviewer: { name: string; role: string; qualification?: string };
|
|
149
|
+
interpretation: string;
|
|
150
|
+
appliesTo?: { action?: string; path?: string };
|
|
151
|
+
}): Promise<{ workId: WorkId; text: string }>;
|
|
152
|
+
/** The calls held for approval across the workspace. */
|
|
153
|
+
approvals(): Promise<HeldApproval[]>;
|
|
154
|
+
/** The work the model is on right now, if any. */
|
|
155
|
+
currentWork(): WorkId | undefined;
|
|
156
|
+
/** Ends the conversation. The record stays; a work left in progress stays in progress. */
|
|
107
157
|
close(): Promise<Work>;
|
|
108
158
|
}
|
|
109
159
|
|
|
110
|
-
|
|
160
|
+
interface TaskState {
|
|
161
|
+
id: WorkId;
|
|
162
|
+
modelCalls: number;
|
|
163
|
+
/** Ids of the calls the model made inside this work, to fold their results away when it closes. */
|
|
164
|
+
callIds: Set<string>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Opens a conversation, recorded as a work of type "session", between the person and the model.
|
|
169
|
+
* The loop is a client of the runtime: it creates works, calls tools and closes works through
|
|
170
|
+
* the same MCP tools any other agent uses, and records its own model calls with work_record.
|
|
171
|
+
*/
|
|
111
172
|
export async function createSession(
|
|
112
|
-
|
|
113
|
-
options: SessionOptions
|
|
173
|
+
client: RuntimeClient,
|
|
174
|
+
options: SessionOptions,
|
|
114
175
|
): Promise<Session> {
|
|
115
|
-
const model = options
|
|
116
|
-
const { principal, profession } = runtime.config;
|
|
176
|
+
const { model, config } = options;
|
|
117
177
|
const agentName =
|
|
118
|
-
options.agentName ?? pickAgentName(
|
|
119
|
-
const
|
|
178
|
+
options.agentName ?? pickAgentName(config.company.language, await namesInUse(client));
|
|
179
|
+
const opened = await client.call("work_create", {
|
|
120
180
|
objective: "会話",
|
|
121
|
-
principal: principal.id,
|
|
122
|
-
profession: profession.id,
|
|
123
181
|
type: SESSION_WORK_TYPE,
|
|
124
|
-
agentName,
|
|
182
|
+
agent_name: agentName,
|
|
125
183
|
});
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
184
|
+
if (opened.isError) throw new Error(`could not open a session: ${opened.text}`);
|
|
185
|
+
const session = jsonOf(opened) as Work;
|
|
186
|
+
const id = session.id;
|
|
129
187
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
return handle.current();
|
|
147
|
-
}),
|
|
188
|
+
/** The session's events as the projection needs them, kept in memory; the runtime holds the record. */
|
|
189
|
+
const events: AnyEvent[] = [];
|
|
190
|
+
let seq = 0;
|
|
191
|
+
const local = <T extends EventType>(type: T, payload: EventPayloads[T]): Event<T> => {
|
|
192
|
+
const now = new Date().toISOString();
|
|
193
|
+
seq += 1;
|
|
194
|
+
return {
|
|
195
|
+
v: 1,
|
|
196
|
+
id: newEventId(),
|
|
197
|
+
workId: id,
|
|
198
|
+
seq,
|
|
199
|
+
type,
|
|
200
|
+
payload,
|
|
201
|
+
occurredAt: now,
|
|
202
|
+
recordedAt: now,
|
|
203
|
+
} as Event<T>;
|
|
148
204
|
};
|
|
149
|
-
|
|
205
|
+
events.push(
|
|
206
|
+
local("work.created", {
|
|
207
|
+
objective: "会話",
|
|
208
|
+
principal: config.principal.id,
|
|
209
|
+
profession: config.profession.id,
|
|
210
|
+
type: SESSION_WORK_TYPE,
|
|
211
|
+
agentName,
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
let task: TaskState | undefined;
|
|
215
|
+
let candidate: { id: WorkId; objective: string; status: string } | undefined;
|
|
216
|
+
let held: HeldApproval | undefined;
|
|
217
|
+
/** Rules the person said yes to for the rest of this conversation. */
|
|
218
|
+
const standing = new Set<string>();
|
|
150
219
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
.
|
|
157
|
-
|
|
220
|
+
// The basics (time, business date, folder) enter the conversation as a recorded prompt, so the
|
|
221
|
+
// projection stays a function of the record. The model can refresh them with the context tool.
|
|
222
|
+
const basics = await client.call("context", {});
|
|
223
|
+
const info = basics.isError ? undefined : (jsonOf(basics) as Record<string, unknown> | undefined);
|
|
224
|
+
const basicsText = info
|
|
225
|
+
? `現在時刻は ${info.now}(${info.timezone})、今日の業務日は ${info.business_date}。会社フォルダは ${info.workspace}。日付や時刻が要るときは context を呼ぶ。`
|
|
226
|
+
: undefined;
|
|
158
227
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
): Promise<
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
228
|
+
/** Records one of the client's own events on a work through the runtime, and reports it. */
|
|
229
|
+
const record = async <T extends EventType>(
|
|
230
|
+
workId: WorkId,
|
|
231
|
+
type: T,
|
|
232
|
+
payload: EventPayloads[T],
|
|
233
|
+
): Promise<void> => {
|
|
234
|
+
const event = local(type, payload);
|
|
235
|
+
const file = eventToFile({ ...event, workId });
|
|
236
|
+
const result = await client.call("work_record", {
|
|
237
|
+
work_id: workId,
|
|
238
|
+
type,
|
|
239
|
+
payload: file.payload,
|
|
240
|
+
});
|
|
241
|
+
if (result.isError) throw new Error(`work_record failed: ${result.text}`);
|
|
242
|
+
await options.onEvent?.(workId, { ...event, workId });
|
|
243
|
+
};
|
|
244
|
+
/** Records a model event on the session and, while a work is open, on that work as well. */
|
|
245
|
+
const recordModelEvent = async <T extends EventType>(type: T, payload: EventPayloads[T]) => {
|
|
246
|
+
events.push(local(type, payload));
|
|
247
|
+
await record(id, type, payload);
|
|
248
|
+
if (task) await record(task.id, type, payload);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
if (basicsText) {
|
|
252
|
+
events.push(local("prompt.expanded", { name: "context", source: "runtime", text: basicsText }));
|
|
253
|
+
await record(id, "prompt.expanded", { name: "context", source: "runtime", text: basicsText });
|
|
171
254
|
}
|
|
172
|
-
}
|
|
173
255
|
|
|
174
|
-
async
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
signal: AbortSignal | undefined,
|
|
180
|
-
): Promise<TurnResult> {
|
|
181
|
-
const description = model.describe();
|
|
182
|
-
const config = {
|
|
183
|
-
...runtime.config,
|
|
256
|
+
const describedTools = async (): Promise<ToolDefinition[]> =>
|
|
257
|
+
(await client.listTools()).filter((t) => !LOOP_ONLY_TOOLS.has(t.name));
|
|
258
|
+
|
|
259
|
+
const promptConfig = {
|
|
260
|
+
...config,
|
|
184
261
|
profession: {
|
|
185
|
-
...
|
|
186
|
-
instructions: `${
|
|
262
|
+
...config.profession,
|
|
263
|
+
instructions: `${config.profession.instructions.trim()}\n\n${ROLE}`,
|
|
187
264
|
},
|
|
188
265
|
};
|
|
189
|
-
const turnStart = (await handle.events()).length;
|
|
190
|
-
const tools = [...SESSION_TOOLS];
|
|
191
|
-
let modelCalls = 0;
|
|
192
|
-
let toolCalls = 0;
|
|
193
266
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
267
|
+
async function runTurn(signal: AbortSignal | undefined): Promise<TurnResult> {
|
|
268
|
+
const description = model.describe();
|
|
269
|
+
const tools = await describedTools();
|
|
270
|
+
let modelCalls = 0;
|
|
271
|
+
let toolCalls = 0;
|
|
272
|
+
for (;;) {
|
|
273
|
+
if (signal?.aborted) return { reply: "", stopped: "aborted" };
|
|
274
|
+
if (modelCalls >= TURN_LIMITS.modelCalls) {
|
|
275
|
+
return {
|
|
276
|
+
reply: "",
|
|
277
|
+
stopped: "turn_limit",
|
|
278
|
+
detail: `model calls in one turn (${TURN_LIMITS.modelCalls})`,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
const projection = buildProjection({
|
|
282
|
+
events,
|
|
283
|
+
config: promptConfig,
|
|
284
|
+
tools,
|
|
285
|
+
providerId: model.id,
|
|
286
|
+
budget: {
|
|
287
|
+
modelCallsLeft: TURN_LIMITS.modelCalls - modelCalls,
|
|
288
|
+
toolCallsLeft: TURN_LIMITS.toolCalls - toolCalls,
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
if (task && task.modelCalls >= config.limits.maxModelCalls) {
|
|
292
|
+
await callTool(
|
|
293
|
+
{
|
|
294
|
+
id: `call_limit_${task.id}`,
|
|
295
|
+
name: "work_fail",
|
|
296
|
+
input: {
|
|
297
|
+
reason: "limit_reached",
|
|
298
|
+
detail: `${config.limits.maxModelCalls} model calls`,
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
signal,
|
|
302
|
+
);
|
|
303
|
+
return {
|
|
304
|
+
reply: "",
|
|
305
|
+
stopped: "turn_limit",
|
|
306
|
+
detail: `model calls in one work (${config.limits.maxModelCalls})`,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
await recordModelEvent("model.requested", {
|
|
217
310
|
provider: model.id,
|
|
218
311
|
model: description.model,
|
|
219
312
|
messageCount: projection.messages.length,
|
|
220
313
|
toolNames: tools.map((t) => t.name),
|
|
221
|
-
},
|
|
222
|
-
});
|
|
223
|
-
modelCalls += 1;
|
|
224
|
-
|
|
225
|
-
let response: ModelResponse;
|
|
226
|
-
try {
|
|
227
|
-
response = await model.generate(
|
|
228
|
-
{
|
|
229
|
-
system: projection.system,
|
|
230
|
-
messages: projection.messages,
|
|
231
|
-
tools: projection.tools,
|
|
232
|
-
maxOutputTokens: runtime.config.limits.maxOutputTokens,
|
|
233
|
-
budget: projection.budget,
|
|
234
|
-
stableMessages: projection.messages.length - 1,
|
|
235
|
-
...(runtime.config.model.options && { providerOptions: runtime.config.model.options }),
|
|
236
|
-
},
|
|
237
|
-
signal,
|
|
238
|
-
);
|
|
239
|
-
} catch (err) {
|
|
240
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
241
|
-
await handle.append({
|
|
242
|
-
type: "model.failed",
|
|
243
|
-
payload: { code: isOpenshainError(err) ? err.code : "model_error", message },
|
|
244
314
|
});
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
315
|
+
modelCalls += 1;
|
|
316
|
+
if (task) task.modelCalls += 1;
|
|
317
|
+
|
|
318
|
+
let response: ModelResponse;
|
|
319
|
+
try {
|
|
320
|
+
response = await model.generate(
|
|
321
|
+
{
|
|
322
|
+
system: projection.system,
|
|
323
|
+
messages: projection.messages,
|
|
324
|
+
tools: projection.tools,
|
|
325
|
+
maxOutputTokens: config.limits.maxOutputTokens,
|
|
326
|
+
budget: projection.budget,
|
|
327
|
+
stableMessages: projection.messages.length - 1,
|
|
328
|
+
...(config.model?.options && { providerOptions: config.model.options }),
|
|
329
|
+
},
|
|
330
|
+
signal,
|
|
331
|
+
);
|
|
332
|
+
} catch (err) {
|
|
333
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
334
|
+
await recordModelEvent("model.failed", { code: "model_error", message });
|
|
335
|
+
return { reply: "", stopped: signal?.aborted ? "aborted" : "model_error", detail: message };
|
|
336
|
+
}
|
|
337
|
+
await recordModelEvent("model.completed", {
|
|
250
338
|
stopReason: response.stopReason,
|
|
251
339
|
content: response.message.content,
|
|
252
|
-
...(
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
await handle.append({
|
|
256
|
-
type: "usage.recorded",
|
|
257
|
-
payload: {
|
|
340
|
+
...(config.debug?.persistRaw && response.raw !== undefined && { raw: response.raw }),
|
|
341
|
+
});
|
|
342
|
+
await recordModelEvent("usage.recorded", {
|
|
258
343
|
kind: "model_inference",
|
|
259
344
|
provider: model.id,
|
|
260
345
|
model: description.model,
|
|
261
346
|
usage: response.usage,
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
347
|
+
});
|
|
348
|
+
const text = textOf(response.message.content);
|
|
349
|
+
switch (response.stopReason) {
|
|
350
|
+
case "end_turn":
|
|
351
|
+
return { reply: text };
|
|
352
|
+
case "tool_call": {
|
|
353
|
+
const calls = response.message.content.filter((p) => p.type === "tool_call");
|
|
354
|
+
for (const [index, call] of calls.entries()) {
|
|
355
|
+
if (signal?.aborted) {
|
|
356
|
+
await closeRest(calls, index, "the turn stopped before this call ran");
|
|
357
|
+
return { reply: text, stopped: "aborted" };
|
|
358
|
+
}
|
|
359
|
+
if (toolCalls >= TURN_LIMITS.toolCalls) {
|
|
360
|
+
await closeRest(calls, index, "the turn reached its limit before this call ran");
|
|
361
|
+
return {
|
|
362
|
+
reply: text,
|
|
363
|
+
stopped: "turn_limit",
|
|
364
|
+
detail: `tool calls in one turn (${TURN_LIMITS.toolCalls})`,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
toolCalls += 1;
|
|
368
|
+
const outcome = await callTool(call, signal);
|
|
369
|
+
if (outcome === "withdrawn") {
|
|
370
|
+
await closeRest(calls, index + 1, "the turn stopped before this call ran");
|
|
371
|
+
return { reply: text, stopped: "aborted" };
|
|
372
|
+
}
|
|
373
|
+
if (outcome === "held" && held) {
|
|
374
|
+
await closeRest(
|
|
375
|
+
calls,
|
|
376
|
+
index + 1,
|
|
377
|
+
"the turn stopped for an approval before this call ran",
|
|
378
|
+
);
|
|
379
|
+
return { reply: text, stopped: "approval", approval: held };
|
|
380
|
+
}
|
|
279
381
|
}
|
|
280
|
-
|
|
281
|
-
await callSessionTool(runtime, handle, model, options, signal, call);
|
|
382
|
+
break;
|
|
282
383
|
}
|
|
283
|
-
|
|
384
|
+
case "max_tokens":
|
|
385
|
+
return { reply: text, stopped: "max_tokens" };
|
|
386
|
+
case "refusal":
|
|
387
|
+
return { reply: text, stopped: "refusal" };
|
|
388
|
+
default:
|
|
389
|
+
return {
|
|
390
|
+
reply: text,
|
|
391
|
+
stopped: "model_error",
|
|
392
|
+
detail: `unexpected stop reason "${response.stopReason}"`,
|
|
393
|
+
};
|
|
284
394
|
}
|
|
285
|
-
case "max_tokens":
|
|
286
|
-
return { reply: text, stopped: "max_tokens" };
|
|
287
|
-
case "refusal":
|
|
288
|
-
return { reply: text, stopped: "refusal" };
|
|
289
|
-
default:
|
|
290
|
-
return {
|
|
291
|
-
reply: text,
|
|
292
|
-
stopped: "model_error",
|
|
293
|
-
detail: `unexpected stop reason "${response.stopReason}"`,
|
|
294
|
-
};
|
|
295
395
|
}
|
|
296
|
-
// Nothing of the turn's own events is needed below; the projection rebuilds from the log.
|
|
297
|
-
void turnStart;
|
|
298
396
|
}
|
|
299
|
-
}
|
|
300
397
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
payload: {
|
|
398
|
+
/**
|
|
399
|
+
* One tool call of the model, through the runtime. Keeps track of the work the model is on,
|
|
400
|
+
* asks the person when the runtime says a question is pending, and mirrors the call and its
|
|
401
|
+
* result into the session's projection.
|
|
402
|
+
*/
|
|
403
|
+
async function callTool(
|
|
404
|
+
call: { id: string; name: string; input: unknown },
|
|
405
|
+
signal: AbortSignal | undefined,
|
|
406
|
+
): Promise<"done" | "withdrawn" | "held"> {
|
|
407
|
+
const workId = task?.id ?? id;
|
|
408
|
+
events.push(
|
|
409
|
+
local("tool.called", {
|
|
314
410
|
callId: call.id,
|
|
411
|
+
provider: "runtime",
|
|
315
412
|
name: call.name,
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
},
|
|
319
|
-
});
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
322
|
-
const validation: InputValidation = validate(call.input);
|
|
323
|
-
if (!validation.ok) {
|
|
324
|
-
await handle.append({
|
|
325
|
-
type: "tool.rejected",
|
|
326
|
-
payload: {
|
|
327
|
-
callId: call.id,
|
|
328
|
-
name: call.name,
|
|
329
|
-
code: "schema_mismatch",
|
|
330
|
-
reason: `input does not match the schema of ${call.name}: ${validation.reason}`,
|
|
331
|
-
},
|
|
332
|
-
});
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
await handle.append({
|
|
336
|
-
type: "tool.called",
|
|
337
|
-
payload: { callId: call.id, provider: RUNTIME_PROVIDER_ID, name: call.name, input: call.input },
|
|
338
|
-
});
|
|
339
|
-
let result: { content: ToolContent[]; isError?: boolean };
|
|
340
|
-
try {
|
|
341
|
-
result = await runSessionTool(
|
|
342
|
-
runtime,
|
|
343
|
-
handle,
|
|
344
|
-
model,
|
|
345
|
-
options,
|
|
346
|
-
signal,
|
|
347
|
-
call.name,
|
|
348
|
-
call.input as Record<string, unknown>,
|
|
413
|
+
input: call.input,
|
|
414
|
+
}),
|
|
349
415
|
);
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
416
|
+
await options.onEvent?.(workId, events.at(-1) as AnyEvent);
|
|
417
|
+
// The loop drives these itself; a model that calls them is refused before the runtime sees it.
|
|
418
|
+
const refusal = LOOP_ONLY_TOOLS.has(call.name)
|
|
419
|
+
? `${call.name} is the loop's own; it is not a tool for the model`
|
|
420
|
+
: !task && (call.name === "work_complete" || call.name === "work_fail")
|
|
421
|
+
? `${call.name} needs a work of its own: no work is open; start one with work_create`
|
|
422
|
+
: undefined;
|
|
423
|
+
if (refusal) {
|
|
424
|
+
await finish(call.id, {
|
|
425
|
+
content: [{ type: "text", text: refusal }],
|
|
426
|
+
isError: true,
|
|
427
|
+
text: "",
|
|
428
|
+
});
|
|
429
|
+
return "done";
|
|
430
|
+
}
|
|
431
|
+
const input =
|
|
432
|
+
call.name === "work_create" && call.input && typeof call.input === "object"
|
|
433
|
+
? { ...(call.input as Record<string, unknown>), parent: id, agent_name: agentName }
|
|
434
|
+
: call.input;
|
|
435
|
+
let result: ClientResult;
|
|
436
|
+
try {
|
|
437
|
+
result = await client.call(call.name, input, signal);
|
|
438
|
+
} catch (err) {
|
|
439
|
+
result = {
|
|
440
|
+
content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
|
|
441
|
+
isError: true,
|
|
442
|
+
text: "",
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
task?.callIds.add(call.id);
|
|
446
|
+
const data = result.isError
|
|
447
|
+
? undefined
|
|
448
|
+
: (jsonOf(result) as Record<string, unknown> | undefined);
|
|
361
449
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
450
|
+
if (
|
|
451
|
+
!result.isError &&
|
|
452
|
+
(call.name === "work_create" || call.name === "work_select") &&
|
|
453
|
+
data?.id
|
|
454
|
+
) {
|
|
455
|
+
const workId = data.id as WorkId;
|
|
456
|
+
const history = (data as { history?: { modelCalls?: number } }).history;
|
|
457
|
+
task = {
|
|
458
|
+
id: workId,
|
|
459
|
+
modelCalls: typeof history?.modelCalls === "number" ? history.modelCalls : 0,
|
|
460
|
+
callIds: new Set([call.id]),
|
|
461
|
+
};
|
|
462
|
+
candidate = undefined;
|
|
463
|
+
await options.onEvent?.(
|
|
464
|
+
workId,
|
|
465
|
+
local("work.status_changed", {
|
|
466
|
+
from: "queued",
|
|
467
|
+
to: String(data.status ?? "in_progress"),
|
|
468
|
+
reason: call.name,
|
|
469
|
+
}),
|
|
470
|
+
);
|
|
471
|
+
// A selected work that waits for an answer gets it now, oldest question first.
|
|
472
|
+
if (call.name === "work_select" && data.status === "waiting_input") {
|
|
473
|
+
const answered = await answerPending(workId, signal);
|
|
474
|
+
if (answered === "withdrawn") {
|
|
475
|
+
await finish(call.id, result);
|
|
476
|
+
return "withdrawn";
|
|
477
|
+
}
|
|
478
|
+
if (answered.length > 0) {
|
|
479
|
+
result = {
|
|
480
|
+
...result,
|
|
481
|
+
text: "",
|
|
482
|
+
content: [
|
|
483
|
+
...result.content,
|
|
484
|
+
{ type: "text", text: `answers recorded: ${JSON.stringify(answered)}` },
|
|
485
|
+
],
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if (!result.isError && call.name === ASK_USER_TOOL_NAME && data?.pending === true && task) {
|
|
491
|
+
const asked = task.id;
|
|
492
|
+
const question = String(data.question ?? "");
|
|
493
|
+
if (!options.onInput) {
|
|
494
|
+
result = {
|
|
495
|
+
...result,
|
|
496
|
+
text: "",
|
|
376
497
|
content: [
|
|
377
498
|
{
|
|
378
499
|
type: "text",
|
|
379
|
-
text:
|
|
500
|
+
text: "the work waits for the person's answer; it can be resumed later",
|
|
380
501
|
},
|
|
381
502
|
],
|
|
382
|
-
isError: true,
|
|
383
503
|
};
|
|
504
|
+
} else {
|
|
505
|
+
let answer: string;
|
|
506
|
+
try {
|
|
507
|
+
answer = await options.onInput(asked, question);
|
|
508
|
+
} catch {
|
|
509
|
+
// The person took the question back: the work stays waiting_input.
|
|
510
|
+
await finish(call.id, {
|
|
511
|
+
content: [{ type: "text", text: "the person withdrew the question; the work waits" }],
|
|
512
|
+
isError: true,
|
|
513
|
+
text: "",
|
|
514
|
+
});
|
|
515
|
+
return "withdrawn";
|
|
516
|
+
}
|
|
517
|
+
const answered = await client.call(
|
|
518
|
+
"work_answer",
|
|
519
|
+
{ call_id: data.call_id, answer },
|
|
520
|
+
signal,
|
|
521
|
+
);
|
|
522
|
+
result = answered.isError
|
|
523
|
+
? answered
|
|
524
|
+
: { content: [{ type: "text", text: answer }], isError: false, text: answer };
|
|
384
525
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
...(options.onInput && {
|
|
397
|
-
onInput: (q: string) => options.onInput?.(child.id, q) as Promise<string>,
|
|
398
|
-
}),
|
|
399
|
-
...(options.onWorkEvent && {
|
|
400
|
-
onEvent: (e: AnyEvent) => options.onWorkEvent?.(child.id, e),
|
|
401
|
-
}),
|
|
402
|
-
...(signal && { signal }),
|
|
403
|
-
});
|
|
404
|
-
return {
|
|
405
|
-
content: [
|
|
406
|
-
{ type: "json", value: await describeWork(runtime, done, signal?.aborted === true) },
|
|
407
|
-
],
|
|
526
|
+
}
|
|
527
|
+
if (!result.isError && (data?.pending === "approval" || data?.pending === "review") && task) {
|
|
528
|
+
const reviewer = data.reviewer as { role: string; name?: string } | undefined;
|
|
529
|
+
const pending: HeldApproval = {
|
|
530
|
+
approvalId: String(data.approval_id),
|
|
531
|
+
workId: task.id,
|
|
532
|
+
name: call.name,
|
|
533
|
+
input: call.input,
|
|
534
|
+
ruleId: String(data.rule_id ?? ""),
|
|
535
|
+
kind: data.pending === "review" ? "review" : "approval",
|
|
536
|
+
...(reviewer && { reviewer }),
|
|
408
537
|
};
|
|
538
|
+
// A review needs a qualified person, so the turn stops whatever the screen can ask.
|
|
539
|
+
if (pending.kind === "review") {
|
|
540
|
+
held = pending;
|
|
541
|
+
await finish(call.id, {
|
|
542
|
+
content: [
|
|
543
|
+
{
|
|
544
|
+
type: "text",
|
|
545
|
+
text: `held for a review by a ${reviewer?.role ?? "reviewer"} (${pending.approvalId}); the work waits until the reviewer decides`,
|
|
546
|
+
},
|
|
547
|
+
],
|
|
548
|
+
isError: false,
|
|
549
|
+
text: "",
|
|
550
|
+
});
|
|
551
|
+
return "held";
|
|
552
|
+
}
|
|
553
|
+
// With a way to ask, the person decides here and the turn goes on. Without one, the turn
|
|
554
|
+
// ends and the call stays held for /approve or for another client.
|
|
555
|
+
if (!options.onApproval) {
|
|
556
|
+
held = pending;
|
|
557
|
+
await finish(call.id, {
|
|
558
|
+
content: [
|
|
559
|
+
{
|
|
560
|
+
type: "text",
|
|
561
|
+
text: `held for approval ${pending.approvalId}; the person decides before the work goes on`,
|
|
562
|
+
},
|
|
563
|
+
],
|
|
564
|
+
isError: false,
|
|
565
|
+
text: "",
|
|
566
|
+
});
|
|
567
|
+
return "held";
|
|
568
|
+
}
|
|
569
|
+
let answer: ApprovalAnswer;
|
|
570
|
+
if (pending.ruleId !== "" && standing.has(pending.ruleId)) {
|
|
571
|
+
answer = { choice: "approve" };
|
|
572
|
+
} else {
|
|
573
|
+
try {
|
|
574
|
+
answer = await options.onApproval(pending);
|
|
575
|
+
} catch {
|
|
576
|
+
// The person left it undecided: the work stays waiting_approval and the turn ends.
|
|
577
|
+
held = pending;
|
|
578
|
+
await finish(call.id, {
|
|
579
|
+
content: [
|
|
580
|
+
{
|
|
581
|
+
type: "text",
|
|
582
|
+
text: "the person left this call undecided; it is still waiting for their approval and the work stops here",
|
|
583
|
+
},
|
|
584
|
+
],
|
|
585
|
+
isError: true,
|
|
586
|
+
text: "",
|
|
587
|
+
});
|
|
588
|
+
return "held";
|
|
589
|
+
}
|
|
590
|
+
if (answer.choice === "always" && pending.ruleId !== "") standing.add(pending.ruleId);
|
|
591
|
+
}
|
|
592
|
+
const standingNote =
|
|
593
|
+
answer.choice === "always"
|
|
594
|
+
? `この会話では規則 ${pending.ruleId} を常に承認する、と決めた`
|
|
595
|
+
: undefined;
|
|
596
|
+
const comment = [answer.comment, standingNote].filter(Boolean).join(" / ");
|
|
597
|
+
const decided = await client.call(
|
|
598
|
+
"approval_decide",
|
|
599
|
+
{
|
|
600
|
+
approval_id: pending.approvalId,
|
|
601
|
+
decision: answer.choice === "reject" ? "reject" : "approve",
|
|
602
|
+
...(comment !== "" && { comment }),
|
|
603
|
+
},
|
|
604
|
+
signal,
|
|
605
|
+
);
|
|
606
|
+
const outcome = jsonOf(decided) as
|
|
607
|
+
| { result?: { content: ToolContent[]; isError: boolean } }
|
|
608
|
+
| undefined;
|
|
609
|
+
if (decided.isError) {
|
|
610
|
+
// The work is still waiting; nothing the model does next can move it.
|
|
611
|
+
held = pending;
|
|
612
|
+
await finish(call.id, decided);
|
|
613
|
+
return "held";
|
|
614
|
+
}
|
|
615
|
+
if (answer.choice === "reject") {
|
|
616
|
+
const said = answer.comment ? ` They said: ${answer.comment}` : "";
|
|
617
|
+
await finish(call.id, {
|
|
618
|
+
content: [
|
|
619
|
+
{
|
|
620
|
+
type: "text",
|
|
621
|
+
text: `the person refused this call; it did not run and the work is not waiting for anyone.${said} Do not call it again unchanged: say what you would need, or propose another way.`,
|
|
622
|
+
},
|
|
623
|
+
],
|
|
624
|
+
isError: true,
|
|
625
|
+
text: "",
|
|
626
|
+
});
|
|
627
|
+
} else {
|
|
628
|
+
await finish(call.id, {
|
|
629
|
+
content: outcome?.result?.content ?? [{ type: "text", text: "approved" }],
|
|
630
|
+
isError: outcome?.result?.isError ?? false,
|
|
631
|
+
text: "",
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
return "done";
|
|
409
635
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
636
|
+
await finish(call.id, result);
|
|
637
|
+
if (!result.isError && (call.name === "work_complete" || call.name === "work_fail") && task) {
|
|
638
|
+
const closed = task;
|
|
639
|
+
task = undefined;
|
|
640
|
+
foldAway(closed, call.id);
|
|
641
|
+
// The rule that matters most is stated where it is needed, not only in the system prompt:
|
|
642
|
+
// the work is closed and its summary went to the record, so the person has read nothing
|
|
643
|
+
// yet. Smaller models end the turn with an acknowledgement without this.
|
|
644
|
+
const note =
|
|
645
|
+
call.name === "work_complete"
|
|
646
|
+
? `Work ${closed.id} を閉じた。summary は記録に残るだけで、人の画面には出ない。この後の返答で、何をしたかと結果の数字を人に伝える。`
|
|
647
|
+
: `Work ${closed.id} は失敗として閉じた。この後の返答で、どこまで進んで何が起きたかを人に伝える。`;
|
|
648
|
+
events.push(local("prompt.expanded", { name: "work closed", source: "runtime", text: note }));
|
|
649
|
+
await record(id, "prompt.expanded", { name: "work closed", source: "runtime", text: note });
|
|
650
|
+
await options.onEvent?.(
|
|
651
|
+
closed.id,
|
|
652
|
+
local(
|
|
653
|
+
call.name === "work_complete" ? "work.completed" : "work.failed",
|
|
654
|
+
call.name === "work_complete"
|
|
655
|
+
? { summary: String((call.input as { summary?: unknown })?.summary ?? "") }
|
|
656
|
+
: { reason: String((call.input as { reason?: unknown })?.reason ?? ""), detail: "" },
|
|
657
|
+
) as AnyEvent,
|
|
658
|
+
);
|
|
424
659
|
}
|
|
425
|
-
|
|
426
|
-
|
|
660
|
+
return "done";
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Asks the person every question the work still waits on and records the answers. */
|
|
664
|
+
async function answerPending(
|
|
665
|
+
workId: WorkId,
|
|
666
|
+
signal: AbortSignal | undefined,
|
|
667
|
+
): Promise<{ question: string; answer: string }[] | "withdrawn"> {
|
|
668
|
+
if (!options.onInput) return [];
|
|
669
|
+
const got = await client.call("work_get", { id: workId, history: true }, signal);
|
|
670
|
+
const history = (
|
|
671
|
+
jsonOf(got) as { history?: { pending?: { callId: string; question: string }[] } }
|
|
672
|
+
)?.history;
|
|
673
|
+
const answers: { question: string; answer: string }[] = [];
|
|
674
|
+
for (const { callId, question } of history?.pending ?? []) {
|
|
675
|
+
let answer: string;
|
|
427
676
|
try {
|
|
428
|
-
|
|
677
|
+
answer = await options.onInput(workId, question);
|
|
429
678
|
} catch {
|
|
430
|
-
return
|
|
431
|
-
content: [{ type: "text", text: `"${String(input.id)}" is not a work id` }],
|
|
432
|
-
isError: true,
|
|
433
|
-
};
|
|
679
|
+
return "withdrawn";
|
|
434
680
|
}
|
|
435
|
-
const
|
|
436
|
-
|
|
681
|
+
const recorded = await client.call("work_answer", { call_id: callId, answer }, signal);
|
|
682
|
+
if (recorded.isError) throw new Error(recorded.text);
|
|
683
|
+
answers.push({ question, answer });
|
|
437
684
|
}
|
|
438
|
-
|
|
439
|
-
return { content: [{ type: "text", text: `no tool named "${name}"` }], isError: true };
|
|
685
|
+
return answers;
|
|
440
686
|
}
|
|
441
|
-
}
|
|
442
687
|
|
|
443
|
-
/**
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
688
|
+
/**
|
|
689
|
+
* Gives every call from `from` on a result, so that the projection stays well formed: a tool
|
|
690
|
+
* call without a result cannot be sent to a model, and the next turn would refuse to build.
|
|
691
|
+
*/
|
|
692
|
+
async function closeRest(calls: { id: string }[], from: number, text: string): Promise<void> {
|
|
693
|
+
for (const call of calls.slice(from)) {
|
|
694
|
+
await finish(call.id, { content: [{ type: "text", text }], isError: true, text: "" });
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function finish(callId: string, result: ClientResult): Promise<void> {
|
|
699
|
+
const event = local("tool.completed", {
|
|
700
|
+
callId,
|
|
701
|
+
content: result.content,
|
|
702
|
+
isError: result.isError,
|
|
703
|
+
});
|
|
704
|
+
events.push(event);
|
|
705
|
+
// The screen draws from these in order, so the result waits for the caller as the call did.
|
|
706
|
+
await options.onEvent?.(task?.id ?? id, event);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** Once a work is closed, only its summary stays in the conversation: the tool results are folded away. */
|
|
710
|
+
function foldAway(closed: TaskState, closingCallId: string): void {
|
|
711
|
+
for (const event of events) {
|
|
712
|
+
if (event.type !== "tool.completed") continue;
|
|
713
|
+
const payload = (event as Event<"tool.completed">).payload;
|
|
714
|
+
if (!closed.callIds.has(payload.callId) || payload.callId === closingCallId) continue;
|
|
715
|
+
payload.content = [
|
|
716
|
+
{
|
|
717
|
+
type: "text",
|
|
718
|
+
text: `(この結果は Work ${closed.id} を閉じたので省略。要点は work_complete の summary にある)`,
|
|
719
|
+
},
|
|
720
|
+
];
|
|
460
721
|
}
|
|
461
722
|
}
|
|
462
|
-
|
|
463
|
-
work.status === "waiting_input" ? pendingQuestions(events).map((q) => q.question) : [];
|
|
723
|
+
|
|
464
724
|
return {
|
|
465
|
-
id
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
725
|
+
id,
|
|
726
|
+
agentName,
|
|
727
|
+
async turn(text, turnOptions = {}) {
|
|
728
|
+
held = undefined;
|
|
729
|
+
events.push(local("human.message", { text }));
|
|
730
|
+
await record(id, "human.message", { text });
|
|
731
|
+
if (candidate) {
|
|
732
|
+
const note = `候補の Work: ${candidate.id}(status: ${candidate.status}、objective: ${candidate.objective})。この依頼がその objective に沿うなら work_select で続ける。沿わなければ続けず、その旨を伝えて新しい Work を作るか work_list で探し直す。`;
|
|
733
|
+
events.push(
|
|
734
|
+
local("prompt.expanded", { name: "work resume", source: "builtin", text: note }),
|
|
735
|
+
);
|
|
736
|
+
await record(id, "prompt.expanded", { name: "work resume", source: "builtin", text: note });
|
|
737
|
+
}
|
|
738
|
+
try {
|
|
739
|
+
const result = await runTurn(turnOptions.signal);
|
|
740
|
+
return task ? { ...result, work: task.id } : result;
|
|
741
|
+
} finally {
|
|
742
|
+
// Whatever the turn did, the next one starts from the conversation: a work it left open
|
|
743
|
+
// stays as it is and comes back as a candidate through select; a declined candidate is dropped.
|
|
744
|
+
candidate = undefined;
|
|
745
|
+
if (task) {
|
|
746
|
+
task = undefined;
|
|
747
|
+
await client.call("work_select", { id }).catch(() => undefined);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
},
|
|
751
|
+
async select(workId) {
|
|
752
|
+
const got = await client.call("work_get", { id: workId });
|
|
753
|
+
if (got.isError) throw new Error(got.text);
|
|
754
|
+
const work = jsonOf(got) as Work;
|
|
755
|
+
if (isTerminal(work.status))
|
|
756
|
+
throw new Error(`${work.id} は ${work.status} で、続けられません`);
|
|
757
|
+
candidate = { id: work.id, objective: work.objective, status: work.status };
|
|
758
|
+
return work;
|
|
759
|
+
},
|
|
760
|
+
currentWork: () => task?.id,
|
|
761
|
+
async decide(approvalId, decision, comment) {
|
|
762
|
+
const decided = await client.call("approval_decide", {
|
|
763
|
+
approval_id: approvalId,
|
|
764
|
+
decision,
|
|
765
|
+
...(comment !== undefined && { comment }),
|
|
766
|
+
});
|
|
767
|
+
if (decided.isError) throw new Error(decided.text);
|
|
768
|
+
const data = jsonOf(decided) as {
|
|
769
|
+
work_id: string;
|
|
770
|
+
result?: { content: { type: string; text?: string }[]; isError: boolean };
|
|
771
|
+
};
|
|
772
|
+
const workId = data.work_id as WorkId;
|
|
773
|
+
const outcome =
|
|
774
|
+
decision === "reject"
|
|
775
|
+
? "拒否"
|
|
776
|
+
: data.result?.isError
|
|
777
|
+
? `実行して失敗: ${data.result.content.map((c) => c.text ?? "").join("")}`
|
|
778
|
+
: "実行して成功";
|
|
779
|
+
const note = `承認 ${approvalId} を${decision === "approve" ? "承認" : "拒否"}した(${outcome})。Work ${workId} は続けられる。`;
|
|
780
|
+
events.push(local("prompt.expanded", { name: "approval", source: "runtime", text: note }));
|
|
781
|
+
await record(id, "prompt.expanded", { name: "approval", source: "runtime", text: note });
|
|
782
|
+
const got = await client.call("work_get", { id: workId });
|
|
783
|
+
const work = jsonOf(got) as Work | undefined;
|
|
784
|
+
if (work && !isTerminal(work.status)) {
|
|
785
|
+
candidate = { id: work.id, objective: work.objective, status: work.status };
|
|
786
|
+
}
|
|
787
|
+
return { workId, text: note };
|
|
788
|
+
},
|
|
789
|
+
async review(input) {
|
|
790
|
+
const decided = await client.call("review_decide", {
|
|
791
|
+
approval_id: input.approvalId,
|
|
792
|
+
decision: input.decision,
|
|
793
|
+
reviewer: input.reviewer,
|
|
794
|
+
interpretation: input.interpretation,
|
|
795
|
+
...(input.appliesTo && { applies_to: input.appliesTo }),
|
|
796
|
+
});
|
|
797
|
+
if (decided.isError) throw new Error(decided.text);
|
|
798
|
+
const data = jsonOf(decided) as {
|
|
799
|
+
work_id: string;
|
|
800
|
+
decision_id?: string;
|
|
801
|
+
decision_file?: string;
|
|
802
|
+
result?: { isError: boolean };
|
|
803
|
+
};
|
|
804
|
+
const workId = data.work_id as WorkId;
|
|
805
|
+
const note =
|
|
806
|
+
input.decision === "approve"
|
|
807
|
+
? `${input.reviewer.name}(${input.reviewer.role})が承認し、判断を ${data.decision_file} に記録した。Work ${workId} は続けられる。`
|
|
808
|
+
: `${input.reviewer.name}(${input.reviewer.role})が認めなかった。理由: ${input.interpretation}。Work ${workId} は続けられる。`;
|
|
809
|
+
events.push(local("prompt.expanded", { name: "review", source: "runtime", text: note }));
|
|
810
|
+
await record(id, "prompt.expanded", { name: "review", source: "runtime", text: note });
|
|
811
|
+
const got = await client.call("work_get", { id: workId });
|
|
812
|
+
const work = jsonOf(got) as Work | undefined;
|
|
813
|
+
if (work && !isTerminal(work.status)) {
|
|
814
|
+
candidate = { id: work.id, objective: work.objective, status: work.status };
|
|
815
|
+
}
|
|
816
|
+
return { workId, text: note };
|
|
817
|
+
},
|
|
818
|
+
async approvals() {
|
|
819
|
+
const listed = await client.call("approval_list", {});
|
|
820
|
+
if (listed.isError) throw new Error(listed.text);
|
|
821
|
+
const { approvals } = jsonOf(listed) as {
|
|
822
|
+
approvals: {
|
|
823
|
+
approvalId: string;
|
|
824
|
+
work_id: string;
|
|
825
|
+
ruleId: string;
|
|
826
|
+
kind: "approval" | "review";
|
|
827
|
+
reviewer?: { role: string; name?: string };
|
|
828
|
+
call: { name: string; input: unknown };
|
|
829
|
+
}[];
|
|
830
|
+
};
|
|
831
|
+
return approvals.map((a) => ({
|
|
832
|
+
approvalId: a.approvalId,
|
|
833
|
+
workId: a.work_id as WorkId,
|
|
834
|
+
name: a.call.name,
|
|
835
|
+
input: a.call.input,
|
|
836
|
+
ruleId: a.ruleId,
|
|
837
|
+
kind: a.kind,
|
|
838
|
+
...(a.reviewer && { reviewer: a.reviewer }),
|
|
839
|
+
}));
|
|
840
|
+
},
|
|
841
|
+
async close() {
|
|
842
|
+
const selected = await client.call("work_select", { id });
|
|
843
|
+
if (selected.isError) {
|
|
844
|
+
const got = await client.call("work_get", { id });
|
|
845
|
+
return jsonOf(got) as Work;
|
|
846
|
+
}
|
|
847
|
+
const closed = await client.call("work_complete", { summary: "会話を終了" });
|
|
848
|
+
if (closed.isError) throw new Error(closed.text);
|
|
849
|
+
return jsonOf(closed) as Work;
|
|
850
|
+
},
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** The names of the sessions still open, so two people talking at once do not get the same one. */
|
|
855
|
+
async function namesInUse(client: RuntimeClient): Promise<string[]> {
|
|
856
|
+
const listed = await client.call("work_list", {});
|
|
857
|
+
if (listed.isError) return [];
|
|
858
|
+
const { works } = jsonOf(listed) as {
|
|
859
|
+
works: { type: string; status: string; agentName?: string }[];
|
|
480
860
|
};
|
|
861
|
+
return works
|
|
862
|
+
.filter((w) => w.type === SESSION_WORK_TYPE && !isTerminal(w.status as Work["status"]))
|
|
863
|
+
.flatMap((w) => (w.agentName ? [w.agentName] : []));
|
|
481
864
|
}
|
|
482
865
|
|
|
483
866
|
function textOf(content: AssistantPart[]): string {
|
|
@@ -487,22 +870,3 @@ function textOf(content: AssistantPart[]): string {
|
|
|
487
870
|
.join("\n")
|
|
488
871
|
.trim();
|
|
489
872
|
}
|
|
490
|
-
|
|
491
|
-
function observed(
|
|
492
|
-
handle: WorkHandle,
|
|
493
|
-
onEvent: (event: AnyEvent) => void | Promise<void>,
|
|
494
|
-
): WorkHandle {
|
|
495
|
-
return {
|
|
496
|
-
...handle,
|
|
497
|
-
async append(event) {
|
|
498
|
-
const recorded = await handle.append(event);
|
|
499
|
-
await onEvent(recorded);
|
|
500
|
-
return recorded;
|
|
501
|
-
},
|
|
502
|
-
async transition(to, reason) {
|
|
503
|
-
const recorded = await handle.transition(to, reason);
|
|
504
|
-
await onEvent(recorded);
|
|
505
|
-
return recorded;
|
|
506
|
-
},
|
|
507
|
-
};
|
|
508
|
-
}
|