@openshain/agent 0.3.1 → 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/index.d.ts +1 -1
- package/dist/session.d.ts +60 -1
- package/dist/session.js +267 -18
- package/package.json +6 -5
- package/src/index.ts +3 -0
- package/src/session.ts +357 -23
package/NOTICE
ADDED
package/dist/index.d.ts
CHANGED
|
@@ -2,4 +2,4 @@ export { type ClientResult, connectInMemory, jsonOf, type RuntimeClient, wrap }
|
|
|
2
2
|
export { AGENT_NAMES, pickAgentName } from "./names.ts";
|
|
3
3
|
export { ANTHROPIC_PROVIDER_ID, AnthropicProvider, type AnthropicProviderOptions, anthropicProvider, } from "./providers/anthropic.ts";
|
|
4
4
|
export { OPENAI_COMPATIBLE_PROVIDER_ID, OpenAICompatibleProvider, type OpenAICompatibleProviderOptions, openaiCompatibleProvider, } from "./providers/openai-compatible.ts";
|
|
5
|
-
export { createSession, type Session, type SessionOptions, TURN_LIMITS, type TurnResult, type TurnStop, } from "./session.ts";
|
|
5
|
+
export { type ApprovalAnswer, type ApprovalChoice, createSession, type HeldApproval, type Session, type SessionOptions, TURN_LIMITS, type TurnResult, type TurnStop, } from "./session.ts";
|
package/dist/session.d.ts
CHANGED
|
@@ -16,8 +16,40 @@ export interface SessionOptions {
|
|
|
16
16
|
onEvent?: (workId: WorkId, event: AnyEvent) => void | Promise<void>;
|
|
17
17
|
/** Answers a question a work asks the person. Without it, the work waits for input. */
|
|
18
18
|
onInput?: (workId: WorkId, question: string) => Promise<string>;
|
|
19
|
+
/**
|
|
20
|
+
* Asks the person about a call the policy held, while the turn waits. Without it, the turn
|
|
21
|
+
* ends and the call stays held for `/approve` or for another client.
|
|
22
|
+
*/
|
|
23
|
+
onApproval?: (held: HeldApproval) => Promise<ApprovalAnswer>;
|
|
24
|
+
}
|
|
25
|
+
export type TurnStop = "turn_limit" | "aborted" | "max_tokens" | "refusal" | "model_error" | "approval";
|
|
26
|
+
/** A tool call the policy holds until a person decides on it. */
|
|
27
|
+
export interface HeldApproval {
|
|
28
|
+
approvalId: string;
|
|
29
|
+
workId: WorkId;
|
|
30
|
+
name: string;
|
|
31
|
+
input: unknown;
|
|
32
|
+
/** The rule that held it: the unit a person can say yes to for the rest of the conversation. */
|
|
33
|
+
ruleId: string;
|
|
34
|
+
/** review when a qualified reviewer has to decide; a person cannot stand in for one. */
|
|
35
|
+
kind: "approval" | "review";
|
|
36
|
+
reviewer?: {
|
|
37
|
+
role: string;
|
|
38
|
+
name?: string;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* What the person answered about a held call. `always` approves this one and every later call
|
|
43
|
+
* the same rule holds, for this conversation only: nothing is written to authority/, and every
|
|
44
|
+
* call is still recorded as requested and decided.
|
|
45
|
+
*/
|
|
46
|
+
export type ApprovalChoice = "approve" | "always" | "reject";
|
|
47
|
+
/** The person's answer: what they chose, and what they want the agent to know. */
|
|
48
|
+
export interface ApprovalAnswer {
|
|
49
|
+
choice: ApprovalChoice;
|
|
50
|
+
/** Why, in the person's words. Recorded with the decision and handed to the model. */
|
|
51
|
+
comment?: string;
|
|
19
52
|
}
|
|
20
|
-
export type TurnStop = "turn_limit" | "aborted" | "max_tokens" | "refusal" | "model_error";
|
|
21
53
|
export interface TurnResult {
|
|
22
54
|
/** What the model said to the person, possibly empty when the turn stopped early. */
|
|
23
55
|
reply: string;
|
|
@@ -26,6 +58,8 @@ export interface TurnResult {
|
|
|
26
58
|
detail?: string;
|
|
27
59
|
/** The work the turn left open, when it stopped inside one. It can be continued with select. */
|
|
28
60
|
work?: WorkId;
|
|
61
|
+
/** The call held for approval, when the turn stopped for one. */
|
|
62
|
+
approval?: HeldApproval;
|
|
29
63
|
}
|
|
30
64
|
export interface Session {
|
|
31
65
|
readonly id: WorkId;
|
|
@@ -37,6 +71,31 @@ export interface Session {
|
|
|
37
71
|
}): Promise<TurnResult>;
|
|
38
72
|
/** Names a stopped work as the candidate for the next request. The model decides whether to continue it. */
|
|
39
73
|
select(workId: WorkId): Promise<Work>;
|
|
74
|
+
/** Decides a held call as the person. approve runs it; either way the work becomes the candidate. */
|
|
75
|
+
decide(approvalId: string, decision: "approve" | "reject", comment?: string): Promise<{
|
|
76
|
+
workId: WorkId;
|
|
77
|
+
text: string;
|
|
78
|
+
}>;
|
|
79
|
+
/** Records what a qualified reviewer decided about a call held for review. */
|
|
80
|
+
review(input: {
|
|
81
|
+
approvalId: string;
|
|
82
|
+
decision: "approve" | "reject";
|
|
83
|
+
reviewer: {
|
|
84
|
+
name: string;
|
|
85
|
+
role: string;
|
|
86
|
+
qualification?: string;
|
|
87
|
+
};
|
|
88
|
+
interpretation: string;
|
|
89
|
+
appliesTo?: {
|
|
90
|
+
action?: string;
|
|
91
|
+
path?: string;
|
|
92
|
+
};
|
|
93
|
+
}): Promise<{
|
|
94
|
+
workId: WorkId;
|
|
95
|
+
text: string;
|
|
96
|
+
}>;
|
|
97
|
+
/** The calls held for approval across the workspace. */
|
|
98
|
+
approvals(): Promise<HeldApproval[]>;
|
|
40
99
|
/** The work the model is on right now, if any. */
|
|
41
100
|
currentWork(): WorkId | undefined;
|
|
42
101
|
/** Ends the conversation. The record stays; a work left in progress stays in progress. */
|
package/dist/session.js
CHANGED
|
@@ -4,8 +4,41 @@ import { pickAgentName } from "./names.js";
|
|
|
4
4
|
/** How much one turn of the conversation may do before it stops and the person is told. */
|
|
5
5
|
export const TURN_LIMITS = { modelCalls: 25, toolCalls: 40 };
|
|
6
6
|
/** The tools of the runtime that the loop itself drives; the model never sees them. */
|
|
7
|
-
const LOOP_ONLY_TOOLS = new Set([
|
|
8
|
-
|
|
7
|
+
const LOOP_ONLY_TOOLS = new Set([
|
|
8
|
+
"work_record",
|
|
9
|
+
"work_answer",
|
|
10
|
+
// Deciding is the person's, or a qualified reviewer's. A model that could call these would
|
|
11
|
+
// approve the very calls the policy held.
|
|
12
|
+
"approval_decide",
|
|
13
|
+
"review_decide",
|
|
14
|
+
]);
|
|
15
|
+
/**
|
|
16
|
+
* What the conversation adds to the profession's own instructions. Written as sections, and as
|
|
17
|
+
* what to do rather than what to avoid: models differ in how much they say after a tool call,
|
|
18
|
+
* so the screen's side of the contract is stated here instead of left to a model's default.
|
|
19
|
+
*/
|
|
20
|
+
const ROLE = [
|
|
21
|
+
"# 画面",
|
|
22
|
+
"あなたの返答は端末の画面に出る。人に見えるのは、あなたが書いた文と、Tool 呼び出しの名前と引数の 1 行だけ。Tool が返した中身と、work_complete に書いた summary は人には見えない。依頼の答えは返答に書く。",
|
|
23
|
+
"",
|
|
24
|
+
"# 返答の書き方",
|
|
25
|
+
"- 結果から書く。前置き(「承知しました」)と後置き(「ご不明な点があれば」)は書かない",
|
|
26
|
+
"- 依頼が終わったターンでは、何をしたか、答えになる数字(件数、金額、書いたファイルの場所)を書く。次にできることがあれば 1 行で添える",
|
|
27
|
+
"- 見出し、箇条書き、番号、太字、コードブロック、引用が使える。画面がそのまま書式として描く。表は書式にならないので、箇条書きにする",
|
|
28
|
+
"- 数字は Tool が返した値をそのまま書く",
|
|
29
|
+
"- 長さは依頼の大きさに合わせる。1 行で足りる依頼には 1 行で答える",
|
|
30
|
+
"",
|
|
31
|
+
"# 仕事の進め方",
|
|
32
|
+
"- あなたは受付の役でこの人と話す。作業が要るときは work_create で Work を作り(objective は人の言葉で書き、会話で分かった前提を添える)、その Work の中で Tool を呼び、work_complete の summary に記録用の要約を書いて閉じる。summary は記録に残すもの、返答は人に伝えるもの",
|
|
33
|
+
"- 会話の中では Tool を呼べない。ファイルの中身を読まないと答えられない質問も、Work を作って調べる",
|
|
34
|
+
"- /work resume で候補として示された Work は、人の依頼がその objective に沿うときだけ work_select で続ける。沿わなければ続けず、その旨を伝えて新しい Work を作るか work_list で探し直す",
|
|
35
|
+
"- 過去の作業は work_list と work_get で答える",
|
|
36
|
+
"",
|
|
37
|
+
"# 承認と資格者の判断",
|
|
38
|
+
"- 承認が要る呼び出しは止まる。人が決めるまで待ち、同じ呼び出しを繰り返さない",
|
|
39
|
+
"- 実行しないと決められた呼び出しは、理由を読んで別の案を出す。同じ入力で呼び直さない",
|
|
40
|
+
"- 承認と判断は人と資格者の仕事で、あなたの仕事ではない",
|
|
41
|
+
].join("\n");
|
|
9
42
|
/**
|
|
10
43
|
* Opens a conversation, recorded as a work of type "session", between the person and the model.
|
|
11
44
|
* The loop is a client of the runtime: it creates works, calls tools and closes works through
|
|
@@ -49,6 +82,16 @@ export async function createSession(client, options) {
|
|
|
49
82
|
}));
|
|
50
83
|
let task;
|
|
51
84
|
let candidate;
|
|
85
|
+
let held;
|
|
86
|
+
/** Rules the person said yes to for the rest of this conversation. */
|
|
87
|
+
const standing = new Set();
|
|
88
|
+
// The basics (time, business date, folder) enter the conversation as a recorded prompt, so the
|
|
89
|
+
// projection stays a function of the record. The model can refresh them with the context tool.
|
|
90
|
+
const basics = await client.call("context", {});
|
|
91
|
+
const info = basics.isError ? undefined : jsonOf(basics);
|
|
92
|
+
const basicsText = info
|
|
93
|
+
? `現在時刻は ${info.now}(${info.timezone})、今日の業務日は ${info.business_date}。会社フォルダは ${info.workspace}。日付や時刻が要るときは context を呼ぶ。`
|
|
94
|
+
: undefined;
|
|
52
95
|
/** Records one of the client's own events on a work through the runtime, and reports it. */
|
|
53
96
|
const record = async (workId, type, payload) => {
|
|
54
97
|
const event = local(type, payload);
|
|
@@ -69,6 +112,10 @@ export async function createSession(client, options) {
|
|
|
69
112
|
if (task)
|
|
70
113
|
await record(task.id, type, payload);
|
|
71
114
|
};
|
|
115
|
+
if (basicsText) {
|
|
116
|
+
events.push(local("prompt.expanded", { name: "context", source: "runtime", text: basicsText }));
|
|
117
|
+
await record(id, "prompt.expanded", { name: "context", source: "runtime", text: basicsText });
|
|
118
|
+
}
|
|
72
119
|
const describedTools = async () => (await client.listTools()).filter((t) => !LOOP_ONLY_TOOLS.has(t.name));
|
|
73
120
|
const promptConfig = {
|
|
74
121
|
...config,
|
|
@@ -160,10 +207,13 @@ export async function createSession(client, options) {
|
|
|
160
207
|
return { reply: text };
|
|
161
208
|
case "tool_call": {
|
|
162
209
|
const calls = response.message.content.filter((p) => p.type === "tool_call");
|
|
163
|
-
for (const call of calls) {
|
|
164
|
-
if (signal?.aborted)
|
|
210
|
+
for (const [index, call] of calls.entries()) {
|
|
211
|
+
if (signal?.aborted) {
|
|
212
|
+
await closeRest(calls, index, "the turn stopped before this call ran");
|
|
165
213
|
return { reply: text, stopped: "aborted" };
|
|
214
|
+
}
|
|
166
215
|
if (toolCalls >= TURN_LIMITS.toolCalls) {
|
|
216
|
+
await closeRest(calls, index, "the turn reached its limit before this call ran");
|
|
167
217
|
return {
|
|
168
218
|
reply: text,
|
|
169
219
|
stopped: "turn_limit",
|
|
@@ -172,8 +222,14 @@ export async function createSession(client, options) {
|
|
|
172
222
|
}
|
|
173
223
|
toolCalls += 1;
|
|
174
224
|
const outcome = await callTool(call, signal);
|
|
175
|
-
if (outcome === "withdrawn")
|
|
225
|
+
if (outcome === "withdrawn") {
|
|
226
|
+
await closeRest(calls, index + 1, "the turn stopped before this call ran");
|
|
176
227
|
return { reply: text, stopped: "aborted" };
|
|
228
|
+
}
|
|
229
|
+
if (outcome === "held" && held) {
|
|
230
|
+
await closeRest(calls, index + 1, "the turn stopped for an approval before this call ran");
|
|
231
|
+
return { reply: text, stopped: "approval", approval: held };
|
|
232
|
+
}
|
|
177
233
|
}
|
|
178
234
|
break;
|
|
179
235
|
}
|
|
@@ -197,6 +253,13 @@ export async function createSession(client, options) {
|
|
|
197
253
|
*/
|
|
198
254
|
async function callTool(call, signal) {
|
|
199
255
|
const workId = task?.id ?? id;
|
|
256
|
+
events.push(local("tool.called", {
|
|
257
|
+
callId: call.id,
|
|
258
|
+
provider: "runtime",
|
|
259
|
+
name: call.name,
|
|
260
|
+
input: call.input,
|
|
261
|
+
}));
|
|
262
|
+
await options.onEvent?.(workId, events.at(-1));
|
|
200
263
|
// The loop drives these itself; a model that calls them is refused before the runtime sees it.
|
|
201
264
|
const refusal = LOOP_ONLY_TOOLS.has(call.name)
|
|
202
265
|
? `${call.name} is the loop's own; it is not a tool for the model`
|
|
@@ -204,16 +267,13 @@ export async function createSession(client, options) {
|
|
|
204
267
|
? `${call.name} needs a work of its own: no work is open; start one with work_create`
|
|
205
268
|
: undefined;
|
|
206
269
|
if (refusal) {
|
|
207
|
-
finish(call.id, {
|
|
270
|
+
await finish(call.id, {
|
|
271
|
+
content: [{ type: "text", text: refusal }],
|
|
272
|
+
isError: true,
|
|
273
|
+
text: "",
|
|
274
|
+
});
|
|
208
275
|
return "done";
|
|
209
276
|
}
|
|
210
|
-
events.push(local("tool.called", {
|
|
211
|
-
callId: call.id,
|
|
212
|
-
provider: "runtime",
|
|
213
|
-
name: call.name,
|
|
214
|
-
input: call.input,
|
|
215
|
-
}));
|
|
216
|
-
await options.onEvent?.(workId, events.at(-1));
|
|
217
277
|
const input = call.name === "work_create" && call.input && typeof call.input === "object"
|
|
218
278
|
? { ...call.input, parent: id, agent_name: agentName }
|
|
219
279
|
: call.input;
|
|
@@ -252,7 +312,7 @@ export async function createSession(client, options) {
|
|
|
252
312
|
if (call.name === "work_select" && data.status === "waiting_input") {
|
|
253
313
|
const answered = await answerPending(workId, signal);
|
|
254
314
|
if (answered === "withdrawn") {
|
|
255
|
-
finish(call.id, result);
|
|
315
|
+
await finish(call.id, result);
|
|
256
316
|
return "withdrawn";
|
|
257
317
|
}
|
|
258
318
|
if (answered.length > 0) {
|
|
@@ -289,7 +349,7 @@ export async function createSession(client, options) {
|
|
|
289
349
|
}
|
|
290
350
|
catch {
|
|
291
351
|
// The person took the question back: the work stays waiting_input.
|
|
292
|
-
finish(call.id, {
|
|
352
|
+
await finish(call.id, {
|
|
293
353
|
content: [{ type: "text", text: "the person withdrew the question; the work waits" }],
|
|
294
354
|
isError: true,
|
|
295
355
|
text: "",
|
|
@@ -302,11 +362,125 @@ export async function createSession(client, options) {
|
|
|
302
362
|
: { content: [{ type: "text", text: answer }], isError: false, text: answer };
|
|
303
363
|
}
|
|
304
364
|
}
|
|
305
|
-
|
|
365
|
+
if (!result.isError && (data?.pending === "approval" || data?.pending === "review") && task) {
|
|
366
|
+
const reviewer = data.reviewer;
|
|
367
|
+
const pending = {
|
|
368
|
+
approvalId: String(data.approval_id),
|
|
369
|
+
workId: task.id,
|
|
370
|
+
name: call.name,
|
|
371
|
+
input: call.input,
|
|
372
|
+
ruleId: String(data.rule_id ?? ""),
|
|
373
|
+
kind: data.pending === "review" ? "review" : "approval",
|
|
374
|
+
...(reviewer && { reviewer }),
|
|
375
|
+
};
|
|
376
|
+
// A review needs a qualified person, so the turn stops whatever the screen can ask.
|
|
377
|
+
if (pending.kind === "review") {
|
|
378
|
+
held = pending;
|
|
379
|
+
await finish(call.id, {
|
|
380
|
+
content: [
|
|
381
|
+
{
|
|
382
|
+
type: "text",
|
|
383
|
+
text: `held for a review by a ${reviewer?.role ?? "reviewer"} (${pending.approvalId}); the work waits until the reviewer decides`,
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
isError: false,
|
|
387
|
+
text: "",
|
|
388
|
+
});
|
|
389
|
+
return "held";
|
|
390
|
+
}
|
|
391
|
+
// With a way to ask, the person decides here and the turn goes on. Without one, the turn
|
|
392
|
+
// ends and the call stays held for /approve or for another client.
|
|
393
|
+
if (!options.onApproval) {
|
|
394
|
+
held = pending;
|
|
395
|
+
await finish(call.id, {
|
|
396
|
+
content: [
|
|
397
|
+
{
|
|
398
|
+
type: "text",
|
|
399
|
+
text: `held for approval ${pending.approvalId}; the person decides before the work goes on`,
|
|
400
|
+
},
|
|
401
|
+
],
|
|
402
|
+
isError: false,
|
|
403
|
+
text: "",
|
|
404
|
+
});
|
|
405
|
+
return "held";
|
|
406
|
+
}
|
|
407
|
+
let answer;
|
|
408
|
+
if (pending.ruleId !== "" && standing.has(pending.ruleId)) {
|
|
409
|
+
answer = { choice: "approve" };
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
try {
|
|
413
|
+
answer = await options.onApproval(pending);
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
// The person left it undecided: the work stays waiting_approval and the turn ends.
|
|
417
|
+
held = pending;
|
|
418
|
+
await finish(call.id, {
|
|
419
|
+
content: [
|
|
420
|
+
{
|
|
421
|
+
type: "text",
|
|
422
|
+
text: "the person left this call undecided; it is still waiting for their approval and the work stops here",
|
|
423
|
+
},
|
|
424
|
+
],
|
|
425
|
+
isError: true,
|
|
426
|
+
text: "",
|
|
427
|
+
});
|
|
428
|
+
return "held";
|
|
429
|
+
}
|
|
430
|
+
if (answer.choice === "always" && pending.ruleId !== "")
|
|
431
|
+
standing.add(pending.ruleId);
|
|
432
|
+
}
|
|
433
|
+
const standingNote = answer.choice === "always"
|
|
434
|
+
? `この会話では規則 ${pending.ruleId} を常に承認する、と決めた`
|
|
435
|
+
: undefined;
|
|
436
|
+
const comment = [answer.comment, standingNote].filter(Boolean).join(" / ");
|
|
437
|
+
const decided = await client.call("approval_decide", {
|
|
438
|
+
approval_id: pending.approvalId,
|
|
439
|
+
decision: answer.choice === "reject" ? "reject" : "approve",
|
|
440
|
+
...(comment !== "" && { comment }),
|
|
441
|
+
}, signal);
|
|
442
|
+
const outcome = jsonOf(decided);
|
|
443
|
+
if (decided.isError) {
|
|
444
|
+
// The work is still waiting; nothing the model does next can move it.
|
|
445
|
+
held = pending;
|
|
446
|
+
await finish(call.id, decided);
|
|
447
|
+
return "held";
|
|
448
|
+
}
|
|
449
|
+
if (answer.choice === "reject") {
|
|
450
|
+
const said = answer.comment ? ` They said: ${answer.comment}` : "";
|
|
451
|
+
await finish(call.id, {
|
|
452
|
+
content: [
|
|
453
|
+
{
|
|
454
|
+
type: "text",
|
|
455
|
+
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.`,
|
|
456
|
+
},
|
|
457
|
+
],
|
|
458
|
+
isError: true,
|
|
459
|
+
text: "",
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
await finish(call.id, {
|
|
464
|
+
content: outcome?.result?.content ?? [{ type: "text", text: "approved" }],
|
|
465
|
+
isError: outcome?.result?.isError ?? false,
|
|
466
|
+
text: "",
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
return "done";
|
|
470
|
+
}
|
|
471
|
+
await finish(call.id, result);
|
|
306
472
|
if (!result.isError && (call.name === "work_complete" || call.name === "work_fail") && task) {
|
|
307
473
|
const closed = task;
|
|
308
474
|
task = undefined;
|
|
309
475
|
foldAway(closed, call.id);
|
|
476
|
+
// The rule that matters most is stated where it is needed, not only in the system prompt:
|
|
477
|
+
// the work is closed and its summary went to the record, so the person has read nothing
|
|
478
|
+
// yet. Smaller models end the turn with an acknowledgement without this.
|
|
479
|
+
const note = call.name === "work_complete"
|
|
480
|
+
? `Work ${closed.id} を閉じた。summary は記録に残るだけで、人の画面には出ない。この後の返答で、何をしたかと結果の数字を人に伝える。`
|
|
481
|
+
: `Work ${closed.id} は失敗として閉じた。この後の返答で、どこまで進んで何が起きたかを人に伝える。`;
|
|
482
|
+
events.push(local("prompt.expanded", { name: "work closed", source: "runtime", text: note }));
|
|
483
|
+
await record(id, "prompt.expanded", { name: "work closed", source: "runtime", text: note });
|
|
310
484
|
await options.onEvent?.(closed.id, local(call.name === "work_complete" ? "work.completed" : "work.failed", call.name === "work_complete"
|
|
311
485
|
? { summary: String(call.input?.summary ?? "") }
|
|
312
486
|
: { reason: String(call.input?.reason ?? ""), detail: "" }));
|
|
@@ -335,14 +509,24 @@ export async function createSession(client, options) {
|
|
|
335
509
|
}
|
|
336
510
|
return answers;
|
|
337
511
|
}
|
|
338
|
-
|
|
512
|
+
/**
|
|
513
|
+
* Gives every call from `from` on a result, so that the projection stays well formed: a tool
|
|
514
|
+
* call without a result cannot be sent to a model, and the next turn would refuse to build.
|
|
515
|
+
*/
|
|
516
|
+
async function closeRest(calls, from, text) {
|
|
517
|
+
for (const call of calls.slice(from)) {
|
|
518
|
+
await finish(call.id, { content: [{ type: "text", text }], isError: true, text: "" });
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
async function finish(callId, result) {
|
|
339
522
|
const event = local("tool.completed", {
|
|
340
523
|
callId,
|
|
341
524
|
content: result.content,
|
|
342
525
|
isError: result.isError,
|
|
343
526
|
});
|
|
344
527
|
events.push(event);
|
|
345
|
-
|
|
528
|
+
// The screen draws from these in order, so the result waits for the caller as the call did.
|
|
529
|
+
await options.onEvent?.(task?.id ?? id, event);
|
|
346
530
|
}
|
|
347
531
|
/** Once a work is closed, only its summary stays in the conversation: the tool results are folded away. */
|
|
348
532
|
function foldAway(closed, closingCallId) {
|
|
@@ -364,6 +548,7 @@ export async function createSession(client, options) {
|
|
|
364
548
|
id,
|
|
365
549
|
agentName,
|
|
366
550
|
async turn(text, turnOptions = {}) {
|
|
551
|
+
held = undefined;
|
|
367
552
|
events.push(local("human.message", { text }));
|
|
368
553
|
await record(id, "human.message", { text });
|
|
369
554
|
if (candidate) {
|
|
@@ -396,6 +581,70 @@ export async function createSession(client, options) {
|
|
|
396
581
|
return work;
|
|
397
582
|
},
|
|
398
583
|
currentWork: () => task?.id,
|
|
584
|
+
async decide(approvalId, decision, comment) {
|
|
585
|
+
const decided = await client.call("approval_decide", {
|
|
586
|
+
approval_id: approvalId,
|
|
587
|
+
decision,
|
|
588
|
+
...(comment !== undefined && { comment }),
|
|
589
|
+
});
|
|
590
|
+
if (decided.isError)
|
|
591
|
+
throw new Error(decided.text);
|
|
592
|
+
const data = jsonOf(decided);
|
|
593
|
+
const workId = data.work_id;
|
|
594
|
+
const outcome = decision === "reject"
|
|
595
|
+
? "拒否"
|
|
596
|
+
: data.result?.isError
|
|
597
|
+
? `実行して失敗: ${data.result.content.map((c) => c.text ?? "").join("")}`
|
|
598
|
+
: "実行して成功";
|
|
599
|
+
const note = `承認 ${approvalId} を${decision === "approve" ? "承認" : "拒否"}した(${outcome})。Work ${workId} は続けられる。`;
|
|
600
|
+
events.push(local("prompt.expanded", { name: "approval", source: "runtime", text: note }));
|
|
601
|
+
await record(id, "prompt.expanded", { name: "approval", source: "runtime", text: note });
|
|
602
|
+
const got = await client.call("work_get", { id: workId });
|
|
603
|
+
const work = jsonOf(got);
|
|
604
|
+
if (work && !isTerminal(work.status)) {
|
|
605
|
+
candidate = { id: work.id, objective: work.objective, status: work.status };
|
|
606
|
+
}
|
|
607
|
+
return { workId, text: note };
|
|
608
|
+
},
|
|
609
|
+
async review(input) {
|
|
610
|
+
const decided = await client.call("review_decide", {
|
|
611
|
+
approval_id: input.approvalId,
|
|
612
|
+
decision: input.decision,
|
|
613
|
+
reviewer: input.reviewer,
|
|
614
|
+
interpretation: input.interpretation,
|
|
615
|
+
...(input.appliesTo && { applies_to: input.appliesTo }),
|
|
616
|
+
});
|
|
617
|
+
if (decided.isError)
|
|
618
|
+
throw new Error(decided.text);
|
|
619
|
+
const data = jsonOf(decided);
|
|
620
|
+
const workId = data.work_id;
|
|
621
|
+
const note = input.decision === "approve"
|
|
622
|
+
? `${input.reviewer.name}(${input.reviewer.role})が承認し、判断を ${data.decision_file} に記録した。Work ${workId} は続けられる。`
|
|
623
|
+
: `${input.reviewer.name}(${input.reviewer.role})が認めなかった。理由: ${input.interpretation}。Work ${workId} は続けられる。`;
|
|
624
|
+
events.push(local("prompt.expanded", { name: "review", source: "runtime", text: note }));
|
|
625
|
+
await record(id, "prompt.expanded", { name: "review", source: "runtime", text: note });
|
|
626
|
+
const got = await client.call("work_get", { id: workId });
|
|
627
|
+
const work = jsonOf(got);
|
|
628
|
+
if (work && !isTerminal(work.status)) {
|
|
629
|
+
candidate = { id: work.id, objective: work.objective, status: work.status };
|
|
630
|
+
}
|
|
631
|
+
return { workId, text: note };
|
|
632
|
+
},
|
|
633
|
+
async approvals() {
|
|
634
|
+
const listed = await client.call("approval_list", {});
|
|
635
|
+
if (listed.isError)
|
|
636
|
+
throw new Error(listed.text);
|
|
637
|
+
const { approvals } = jsonOf(listed);
|
|
638
|
+
return approvals.map((a) => ({
|
|
639
|
+
approvalId: a.approvalId,
|
|
640
|
+
workId: a.work_id,
|
|
641
|
+
name: a.call.name,
|
|
642
|
+
input: a.call.input,
|
|
643
|
+
ruleId: a.ruleId,
|
|
644
|
+
kind: a.kind,
|
|
645
|
+
...(a.reviewer && { reviewer: a.reviewer }),
|
|
646
|
+
}));
|
|
647
|
+
},
|
|
399
648
|
async close() {
|
|
400
649
|
const selected = await client.call("work_select", { id });
|
|
401
650
|
if (selected.isError) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openshain/agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Tool loop and model providers (bring your own key)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openshain",
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"!src/**/*.test.ts",
|
|
31
31
|
"!src/**/*.test.tsx",
|
|
32
32
|
"README.md",
|
|
33
|
-
"LICENSE"
|
|
33
|
+
"LICENSE",
|
|
34
|
+
"NOTICE"
|
|
34
35
|
],
|
|
35
36
|
"exports": {
|
|
36
37
|
".": {
|
|
@@ -51,12 +52,12 @@
|
|
|
51
52
|
"dependencies": {
|
|
52
53
|
"@anthropic-ai/sdk": "0.123.0",
|
|
53
54
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
54
|
-
"@openshain/core": "0.
|
|
55
|
+
"@openshain/core": "0.4.0",
|
|
55
56
|
"openai": "7.10.0"
|
|
56
57
|
},
|
|
57
58
|
"devDependencies": {
|
|
58
|
-
"@openshain/mcp": "0.
|
|
59
|
-
"@openshain/tools": "0.
|
|
59
|
+
"@openshain/mcp": "0.4.0",
|
|
60
|
+
"@openshain/tools": "0.4.0"
|
|
60
61
|
},
|
|
61
62
|
"publishConfig": {
|
|
62
63
|
"access": "public"
|
package/src/index.ts
CHANGED
package/src/session.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type ModelResponse,
|
|
14
14
|
newEventId,
|
|
15
15
|
SESSION_WORK_TYPE,
|
|
16
|
+
type ToolContent,
|
|
16
17
|
type ToolDefinition,
|
|
17
18
|
type Work,
|
|
18
19
|
type WorkId,
|
|
@@ -24,10 +25,42 @@ import { pickAgentName } from "./names.ts";
|
|
|
24
25
|
export const TURN_LIMITS = { modelCalls: 25, toolCalls: 40 } as const;
|
|
25
26
|
|
|
26
27
|
/** The tools of the runtime that the loop itself drives; the model never sees them. */
|
|
27
|
-
const LOOP_ONLY_TOOLS: ReadonlySet<string> = new Set([
|
|
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",
|
|
35
|
+
]);
|
|
28
36
|
|
|
29
|
-
|
|
30
|
-
|
|
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");
|
|
31
64
|
|
|
32
65
|
export interface SessionOptions {
|
|
33
66
|
/** The model the conversation runs on. The client owns it; the runtime never calls one. */
|
|
@@ -40,9 +73,47 @@ export interface SessionOptions {
|
|
|
40
73
|
onEvent?: (workId: WorkId, event: AnyEvent) => void | Promise<void>;
|
|
41
74
|
/** Answers a question a work asks the person. Without it, the work waits for input. */
|
|
42
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>;
|
|
43
81
|
}
|
|
44
82
|
|
|
45
|
-
export type TurnStop =
|
|
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 };
|
|
102
|
+
}
|
|
103
|
+
|
|
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
|
+
}
|
|
46
117
|
|
|
47
118
|
export interface TurnResult {
|
|
48
119
|
/** What the model said to the person, possibly empty when the turn stopped early. */
|
|
@@ -52,6 +123,8 @@ export interface TurnResult {
|
|
|
52
123
|
detail?: string;
|
|
53
124
|
/** The work the turn left open, when it stopped inside one. It can be continued with select. */
|
|
54
125
|
work?: WorkId;
|
|
126
|
+
/** The call held for approval, when the turn stopped for one. */
|
|
127
|
+
approval?: HeldApproval;
|
|
55
128
|
}
|
|
56
129
|
|
|
57
130
|
export interface Session {
|
|
@@ -62,6 +135,22 @@ export interface Session {
|
|
|
62
135
|
turn(text: string, options?: { signal?: AbortSignal }): Promise<TurnResult>;
|
|
63
136
|
/** Names a stopped work as the candidate for the next request. The model decides whether to continue it. */
|
|
64
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[]>;
|
|
65
154
|
/** The work the model is on right now, if any. */
|
|
66
155
|
currentWork(): WorkId | undefined;
|
|
67
156
|
/** Ends the conversation. The record stays; a work left in progress stays in progress. */
|
|
@@ -124,6 +213,17 @@ export async function createSession(
|
|
|
124
213
|
);
|
|
125
214
|
let task: TaskState | undefined;
|
|
126
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>();
|
|
219
|
+
|
|
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;
|
|
127
227
|
|
|
128
228
|
/** Records one of the client's own events on a work through the runtime, and reports it. */
|
|
129
229
|
const record = async <T extends EventType>(
|
|
@@ -148,6 +248,11 @@ export async function createSession(
|
|
|
148
248
|
if (task) await record(task.id, type, payload);
|
|
149
249
|
};
|
|
150
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 });
|
|
254
|
+
}
|
|
255
|
+
|
|
151
256
|
const describedTools = async (): Promise<ToolDefinition[]> =>
|
|
152
257
|
(await client.listTools()).filter((t) => !LOOP_ONLY_TOOLS.has(t.name));
|
|
153
258
|
|
|
@@ -246,9 +351,13 @@ export async function createSession(
|
|
|
246
351
|
return { reply: text };
|
|
247
352
|
case "tool_call": {
|
|
248
353
|
const calls = response.message.content.filter((p) => p.type === "tool_call");
|
|
249
|
-
for (const call of calls) {
|
|
250
|
-
if (signal?.aborted)
|
|
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
|
+
}
|
|
251
359
|
if (toolCalls >= TURN_LIMITS.toolCalls) {
|
|
360
|
+
await closeRest(calls, index, "the turn reached its limit before this call ran");
|
|
252
361
|
return {
|
|
253
362
|
reply: text,
|
|
254
363
|
stopped: "turn_limit",
|
|
@@ -257,7 +366,18 @@ export async function createSession(
|
|
|
257
366
|
}
|
|
258
367
|
toolCalls += 1;
|
|
259
368
|
const outcome = await callTool(call, signal);
|
|
260
|
-
if (outcome === "withdrawn")
|
|
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
|
+
}
|
|
261
381
|
}
|
|
262
382
|
break;
|
|
263
383
|
}
|
|
@@ -283,18 +403,8 @@ export async function createSession(
|
|
|
283
403
|
async function callTool(
|
|
284
404
|
call: { id: string; name: string; input: unknown },
|
|
285
405
|
signal: AbortSignal | undefined,
|
|
286
|
-
): Promise<"done" | "withdrawn"> {
|
|
406
|
+
): Promise<"done" | "withdrawn" | "held"> {
|
|
287
407
|
const workId = task?.id ?? id;
|
|
288
|
-
// The loop drives these itself; a model that calls them is refused before the runtime sees it.
|
|
289
|
-
const refusal = LOOP_ONLY_TOOLS.has(call.name)
|
|
290
|
-
? `${call.name} is the loop's own; it is not a tool for the model`
|
|
291
|
-
: !task && (call.name === "work_complete" || call.name === "work_fail")
|
|
292
|
-
? `${call.name} needs a work of its own: no work is open; start one with work_create`
|
|
293
|
-
: undefined;
|
|
294
|
-
if (refusal) {
|
|
295
|
-
finish(call.id, { content: [{ type: "text", text: refusal }], isError: true, text: "" });
|
|
296
|
-
return "done";
|
|
297
|
-
}
|
|
298
408
|
events.push(
|
|
299
409
|
local("tool.called", {
|
|
300
410
|
callId: call.id,
|
|
@@ -304,6 +414,20 @@ export async function createSession(
|
|
|
304
414
|
}),
|
|
305
415
|
);
|
|
306
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
|
+
}
|
|
307
431
|
const input =
|
|
308
432
|
call.name === "work_create" && call.input && typeof call.input === "object"
|
|
309
433
|
? { ...(call.input as Record<string, unknown>), parent: id, agent_name: agentName }
|
|
@@ -348,7 +472,7 @@ export async function createSession(
|
|
|
348
472
|
if (call.name === "work_select" && data.status === "waiting_input") {
|
|
349
473
|
const answered = await answerPending(workId, signal);
|
|
350
474
|
if (answered === "withdrawn") {
|
|
351
|
-
finish(call.id, result);
|
|
475
|
+
await finish(call.id, result);
|
|
352
476
|
return "withdrawn";
|
|
353
477
|
}
|
|
354
478
|
if (answered.length > 0) {
|
|
@@ -383,7 +507,7 @@ export async function createSession(
|
|
|
383
507
|
answer = await options.onInput(asked, question);
|
|
384
508
|
} catch {
|
|
385
509
|
// The person took the question back: the work stays waiting_input.
|
|
386
|
-
finish(call.id, {
|
|
510
|
+
await finish(call.id, {
|
|
387
511
|
content: [{ type: "text", text: "the person withdrew the question; the work waits" }],
|
|
388
512
|
isError: true,
|
|
389
513
|
text: "",
|
|
@@ -400,11 +524,129 @@ export async function createSession(
|
|
|
400
524
|
: { content: [{ type: "text", text: answer }], isError: false, text: answer };
|
|
401
525
|
}
|
|
402
526
|
}
|
|
403
|
-
|
|
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 }),
|
|
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";
|
|
635
|
+
}
|
|
636
|
+
await finish(call.id, result);
|
|
404
637
|
if (!result.isError && (call.name === "work_complete" || call.name === "work_fail") && task) {
|
|
405
638
|
const closed = task;
|
|
406
639
|
task = undefined;
|
|
407
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 });
|
|
408
650
|
await options.onEvent?.(
|
|
409
651
|
closed.id,
|
|
410
652
|
local(
|
|
@@ -443,14 +685,25 @@ export async function createSession(
|
|
|
443
685
|
return answers;
|
|
444
686
|
}
|
|
445
687
|
|
|
446
|
-
|
|
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> {
|
|
447
699
|
const event = local("tool.completed", {
|
|
448
700
|
callId,
|
|
449
701
|
content: result.content,
|
|
450
702
|
isError: result.isError,
|
|
451
703
|
});
|
|
452
704
|
events.push(event);
|
|
453
|
-
|
|
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);
|
|
454
707
|
}
|
|
455
708
|
|
|
456
709
|
/** Once a work is closed, only its summary stays in the conversation: the tool results are folded away. */
|
|
@@ -472,6 +725,7 @@ export async function createSession(
|
|
|
472
725
|
id,
|
|
473
726
|
agentName,
|
|
474
727
|
async turn(text, turnOptions = {}) {
|
|
728
|
+
held = undefined;
|
|
475
729
|
events.push(local("human.message", { text }));
|
|
476
730
|
await record(id, "human.message", { text });
|
|
477
731
|
if (candidate) {
|
|
@@ -504,6 +758,86 @@ export async function createSession(
|
|
|
504
758
|
return work;
|
|
505
759
|
},
|
|
506
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
|
+
},
|
|
507
841
|
async close() {
|
|
508
842
|
const selected = await client.call("work_select", { id });
|
|
509
843
|
if (selected.isError) {
|