@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/dist/session.js CHANGED
@@ -1,375 +1,673 @@
1
- import { buildProjection, compileInputValidator, isOpenshainError, isTerminal, parseWorkId, SESSION_WORK_TYPE, } from "@openshain/core";
2
- import { countToolCalls, pendingQuestions, RUNTIME_PROVIDER_ID, runWork } from "./loop.js";
1
+ import { ASK_USER_TOOL_NAME, buildProjection, eventToFile, isTerminal, newEventId, SESSION_WORK_TYPE, } from "@openshain/core";
2
+ import { jsonOf } from "./client.js";
3
3
  import { pickAgentName } from "./names.js";
4
- /** How much one turn of a session may do before the person hears back. */
5
- export const TURN_LIMITS = { modelCalls: 5, toolCalls: 10 };
6
- /** What the session's model may do: hand work out and look work up. It never touches files itself. */
7
- export const SESSION_TOOLS = Object.freeze([
8
- {
9
- name: "work_run",
10
- description: "Start a work for the person's request and drive it until it completes, fails or stops to ask the person a question. Write the objective in the person's own words and add what the conversation established that the work needs to know. Returns the work's id, status, summary, artifacts and usage, or the question it is waiting on.",
11
- inputSchema: {
12
- type: "object",
13
- properties: {
14
- objective: {
15
- type: "string",
16
- minLength: 1,
17
- description: "The request, in the person's words.",
18
- },
19
- type: {
20
- type: "string",
21
- description: "A short label for the kind of work. Defaults to request.",
22
- },
23
- },
24
- required: ["objective"],
25
- additionalProperties: false,
26
- },
27
- effect: "mutate",
28
- },
29
- {
30
- name: "work_list",
31
- description: "The most recent works in this workspace, newest first, without sessions.",
32
- inputSchema: { type: "object", properties: {}, additionalProperties: false },
33
- effect: "observe",
34
- },
35
- {
36
- name: "work_show",
37
- description: "One work by id: its status, summary, artifacts, usage, and who has to act next.",
38
- inputSchema: {
39
- type: "object",
40
- properties: { id: { type: "string", minLength: 1 } },
41
- required: ["id"],
42
- additionalProperties: false,
43
- },
44
- effect: "observe",
45
- },
4
+ /** How much one turn of the conversation may do before it stops and the person is told. */
5
+ export const TURN_LIMITS = { modelCalls: 25, toolCalls: 40 };
6
+ /** The tools of the runtime that the loop itself drives; the model never sees them. */
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",
46
14
  ]);
47
- const validators = new Map(SESSION_TOOLS.map((tool) => [tool.name, compileInputValidator(tool.inputSchema)]));
48
- const ROLE = "あなたはこの会社の社員エージェントとして、受付の役で、この人と話す。作業が要るときは work_run objective を渡して Work にする。objective は人の言葉で書き、会話で分かった前提を添える。会社のファイルは自分では変更しない。ファイルの中身を見ないと答えられない質問も、work_run Work にして調べる。作業の結果は要約して伝える。件数や金額は Work の結果の数字をそのまま書き、計算し直さない。返答は端末の画面に出るので、Markdown の記法や絵文字は使わず、短い文で書く。過去の作業は work_list と work_show で答える。";
49
- /** Opens a conversation, recorded as a work of type "session", between the person and the model. */
50
- export async function createSession(runtime, options = {}) {
51
- const model = options.model ?? runtime.model;
52
- const { principal, profession } = runtime.config;
53
- const agentName = options.agentName ?? pickAgentName(runtime.config.company.language, await namesInUse(runtime));
54
- const created = await runtime.works.create({
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");
42
+ /**
43
+ * Opens a conversation, recorded as a work of type "session", between the person and the model.
44
+ * The loop is a client of the runtime: it creates works, calls tools and closes works through
45
+ * the same MCP tools any other agent uses, and records its own model calls with work_record.
46
+ */
47
+ export async function createSession(client, options) {
48
+ const { model, config } = options;
49
+ const agentName = options.agentName ?? pickAgentName(config.company.language, await namesInUse(client));
50
+ const opened = await client.call("work_create", {
55
51
  objective: "会話",
56
- principal: principal.id,
57
- profession: profession.id,
58
52
  type: SESSION_WORK_TYPE,
59
- agentName,
53
+ agent_name: agentName,
60
54
  });
61
- await withHandle(runtime, created.id, options, (handle) => handle.transition("in_progress", "session opened"));
62
- return {
63
- id: created.id,
55
+ if (opened.isError)
56
+ throw new Error(`could not open a session: ${opened.text}`);
57
+ const session = jsonOf(opened);
58
+ const id = session.id;
59
+ /** The session's events as the projection needs them, kept in memory; the runtime holds the record. */
60
+ const events = [];
61
+ let seq = 0;
62
+ const local = (type, payload) => {
63
+ const now = new Date().toISOString();
64
+ seq += 1;
65
+ return {
66
+ v: 1,
67
+ id: newEventId(),
68
+ workId: id,
69
+ seq,
70
+ type,
71
+ payload,
72
+ occurredAt: now,
73
+ recordedAt: now,
74
+ };
75
+ };
76
+ events.push(local("work.created", {
77
+ objective: "会話",
78
+ principal: config.principal.id,
79
+ profession: config.profession.id,
80
+ type: SESSION_WORK_TYPE,
64
81
  agentName,
65
- turn: (text, turnOptions = {}) => withHandle(runtime, created.id, options, async (handle) => {
66
- await handle.append({ type: "human.message", payload: { text } });
67
- return runTurn(runtime, handle, model, options, turnOptions.signal);
68
- }),
69
- close: () => withHandle(runtime, created.id, options, async (handle) => {
70
- if (isTerminal((await handle.current()).status))
71
- return handle.current();
72
- await handle.append({
73
- type: "evidence.recorded",
74
- payload: { claim: "会話を終了", refs: [], artifacts: [] },
75
- });
76
- await handle.append({ type: "work.completed", payload: { summary: "会話を終了" } });
77
- return handle.current();
78
- }),
82
+ }));
83
+ let task;
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;
95
+ /** Records one of the client's own events on a work through the runtime, and reports it. */
96
+ const record = async (workId, type, payload) => {
97
+ const event = local(type, payload);
98
+ const file = eventToFile({ ...event, workId });
99
+ const result = await client.call("work_record", {
100
+ work_id: workId,
101
+ type,
102
+ payload: file.payload,
103
+ });
104
+ if (result.isError)
105
+ throw new Error(`work_record failed: ${result.text}`);
106
+ await options.onEvent?.(workId, { ...event, workId });
79
107
  };
80
- }
81
- /** The names of the sessions still open, so two people talking at once do not get the same one. */
82
- async function namesInUse(runtime) {
83
- const { works } = await runtime.works.list();
84
- return works
85
- .filter((w) => w.type === SESSION_WORK_TYPE && !isTerminal(w.status))
86
- .flatMap((w) => (w.agentName ? [w.agentName] : []));
87
- }
88
- async function withHandle(runtime, id, options, fn) {
89
- const opened = await runtime.works.open(id);
90
- const handle = options.onEvent ? observed(opened, options.onEvent) : opened;
91
- try {
92
- return await fn(handle);
93
- }
94
- finally {
95
- await handle.close();
108
+ /** Records a model event on the session and, while a work is open, on that work as well. */
109
+ const recordModelEvent = async (type, payload) => {
110
+ events.push(local(type, payload));
111
+ await record(id, type, payload);
112
+ if (task)
113
+ await record(task.id, type, payload);
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 });
96
118
  }
97
- }
98
- async function runTurn(runtime, handle, model, options, signal) {
99
- const description = model.describe();
100
- const config = {
101
- ...runtime.config,
119
+ const describedTools = async () => (await client.listTools()).filter((t) => !LOOP_ONLY_TOOLS.has(t.name));
120
+ const promptConfig = {
121
+ ...config,
102
122
  profession: {
103
- ...runtime.config.profession,
104
- instructions: `${runtime.config.profession.instructions.trim()}\n\n${ROLE}`,
123
+ ...config.profession,
124
+ instructions: `${config.profession.instructions.trim()}\n\n${ROLE}`,
105
125
  },
106
126
  };
107
- const turnStart = (await handle.events()).length;
108
- const tools = [...SESSION_TOOLS];
109
- let modelCalls = 0;
110
- let toolCalls = 0;
111
- for (;;) {
112
- if (signal?.aborted)
113
- return { reply: "", stopped: "aborted" };
114
- if (modelCalls >= TURN_LIMITS.modelCalls) {
115
- return {
116
- reply: "",
117
- stopped: "turn_limit",
118
- detail: `model calls in one turn (${TURN_LIMITS.modelCalls})`,
119
- };
120
- }
121
- const events = await handle.events();
122
- const projection = buildProjection({
123
- events,
124
- config,
125
- tools,
126
- providerId: model.id,
127
- budget: {
128
- modelCallsLeft: TURN_LIMITS.modelCalls - modelCalls,
129
- toolCallsLeft: TURN_LIMITS.toolCalls - toolCalls,
130
- },
131
- });
132
- await handle.append({
133
- type: "model.requested",
134
- payload: {
127
+ async function runTurn(signal) {
128
+ const description = model.describe();
129
+ const tools = await describedTools();
130
+ let modelCalls = 0;
131
+ let toolCalls = 0;
132
+ for (;;) {
133
+ if (signal?.aborted)
134
+ return { reply: "", stopped: "aborted" };
135
+ if (modelCalls >= TURN_LIMITS.modelCalls) {
136
+ return {
137
+ reply: "",
138
+ stopped: "turn_limit",
139
+ detail: `model calls in one turn (${TURN_LIMITS.modelCalls})`,
140
+ };
141
+ }
142
+ const projection = buildProjection({
143
+ events,
144
+ config: promptConfig,
145
+ tools,
146
+ providerId: model.id,
147
+ budget: {
148
+ modelCallsLeft: TURN_LIMITS.modelCalls - modelCalls,
149
+ toolCallsLeft: TURN_LIMITS.toolCalls - toolCalls,
150
+ },
151
+ });
152
+ if (task && task.modelCalls >= config.limits.maxModelCalls) {
153
+ await callTool({
154
+ id: `call_limit_${task.id}`,
155
+ name: "work_fail",
156
+ input: {
157
+ reason: "limit_reached",
158
+ detail: `${config.limits.maxModelCalls} model calls`,
159
+ },
160
+ }, signal);
161
+ return {
162
+ reply: "",
163
+ stopped: "turn_limit",
164
+ detail: `model calls in one work (${config.limits.maxModelCalls})`,
165
+ };
166
+ }
167
+ await recordModelEvent("model.requested", {
135
168
  provider: model.id,
136
169
  model: description.model,
137
170
  messageCount: projection.messages.length,
138
171
  toolNames: tools.map((t) => t.name),
139
- },
140
- });
141
- modelCalls += 1;
142
- let response;
143
- try {
144
- response = await model.generate({
145
- system: projection.system,
146
- messages: projection.messages,
147
- tools: projection.tools,
148
- maxOutputTokens: runtime.config.limits.maxOutputTokens,
149
- budget: projection.budget,
150
- stableMessages: projection.messages.length - 1,
151
- ...(runtime.config.model.options && { providerOptions: runtime.config.model.options }),
152
- }, signal);
153
- }
154
- catch (err) {
155
- const message = err instanceof Error ? err.message : String(err);
156
- await handle.append({
157
- type: "model.failed",
158
- payload: { code: isOpenshainError(err) ? err.code : "model_error", message },
159
172
  });
160
- return { reply: "", stopped: signal?.aborted ? "aborted" : "model_error", detail: message };
161
- }
162
- await handle.append({
163
- type: "model.completed",
164
- payload: {
173
+ modelCalls += 1;
174
+ if (task)
175
+ task.modelCalls += 1;
176
+ let response;
177
+ try {
178
+ response = await model.generate({
179
+ system: projection.system,
180
+ messages: projection.messages,
181
+ tools: projection.tools,
182
+ maxOutputTokens: config.limits.maxOutputTokens,
183
+ budget: projection.budget,
184
+ stableMessages: projection.messages.length - 1,
185
+ ...(config.model?.options && { providerOptions: config.model.options }),
186
+ }, signal);
187
+ }
188
+ catch (err) {
189
+ const message = err instanceof Error ? err.message : String(err);
190
+ await recordModelEvent("model.failed", { code: "model_error", message });
191
+ return { reply: "", stopped: signal?.aborted ? "aborted" : "model_error", detail: message };
192
+ }
193
+ await recordModelEvent("model.completed", {
165
194
  stopReason: response.stopReason,
166
195
  content: response.message.content,
167
- ...(runtime.config.debug.persistRaw && response.raw !== undefined && { raw: response.raw }),
168
- },
169
- });
170
- await handle.append({
171
- type: "usage.recorded",
172
- payload: {
196
+ ...(config.debug?.persistRaw && response.raw !== undefined && { raw: response.raw }),
197
+ });
198
+ await recordModelEvent("usage.recorded", {
173
199
  kind: "model_inference",
174
200
  provider: model.id,
175
201
  model: description.model,
176
202
  usage: response.usage,
177
- },
178
- });
179
- const text = textOf(response.message.content);
180
- switch (response.stopReason) {
181
- case "end_turn":
182
- return { reply: text };
183
- case "tool_call": {
184
- const calls = response.message.content.filter((p) => p.type === "tool_call");
185
- for (const call of calls) {
186
- if (signal?.aborted)
187
- return { reply: text, stopped: "aborted" };
188
- if (toolCalls >= TURN_LIMITS.toolCalls) {
189
- return {
190
- reply: text,
191
- stopped: "turn_limit",
192
- detail: `tool calls in one turn (${TURN_LIMITS.toolCalls})`,
193
- };
203
+ });
204
+ const text = textOf(response.message.content);
205
+ switch (response.stopReason) {
206
+ case "end_turn":
207
+ return { reply: text };
208
+ case "tool_call": {
209
+ const calls = response.message.content.filter((p) => p.type === "tool_call");
210
+ for (const [index, call] of calls.entries()) {
211
+ if (signal?.aborted) {
212
+ await closeRest(calls, index, "the turn stopped before this call ran");
213
+ return { reply: text, stopped: "aborted" };
214
+ }
215
+ if (toolCalls >= TURN_LIMITS.toolCalls) {
216
+ await closeRest(calls, index, "the turn reached its limit before this call ran");
217
+ return {
218
+ reply: text,
219
+ stopped: "turn_limit",
220
+ detail: `tool calls in one turn (${TURN_LIMITS.toolCalls})`,
221
+ };
222
+ }
223
+ toolCalls += 1;
224
+ const outcome = await callTool(call, signal);
225
+ if (outcome === "withdrawn") {
226
+ await closeRest(calls, index + 1, "the turn stopped before this call ran");
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
+ }
194
233
  }
195
- toolCalls += 1;
196
- await callSessionTool(runtime, handle, model, options, signal, call);
234
+ break;
197
235
  }
198
- break;
236
+ case "max_tokens":
237
+ return { reply: text, stopped: "max_tokens" };
238
+ case "refusal":
239
+ return { reply: text, stopped: "refusal" };
240
+ default:
241
+ return {
242
+ reply: text,
243
+ stopped: "model_error",
244
+ detail: `unexpected stop reason "${response.stopReason}"`,
245
+ };
199
246
  }
200
- case "max_tokens":
201
- return { reply: text, stopped: "max_tokens" };
202
- case "refusal":
203
- return { reply: text, stopped: "refusal" };
204
- default:
205
- return {
206
- reply: text,
207
- stopped: "model_error",
208
- detail: `unexpected stop reason "${response.stopReason}"`,
209
- };
210
247
  }
211
- // Nothing of the turn's own events is needed below; the projection rebuilds from the log.
212
- void turnStart;
213
248
  }
214
- }
215
- async function callSessionTool(runtime, handle, model, options, signal, call) {
216
- const validate = validators.get(call.name);
217
- if (!validate) {
218
- await handle.append({
219
- type: "tool.rejected",
220
- payload: {
221
- callId: call.id,
222
- name: call.name,
223
- code: "unknown_tool",
224
- reason: `no tool named "${call.name}"; the session offers ${SESSION_TOOLS.map((t) => t.name).join(", ")}`,
225
- },
226
- });
227
- return;
228
- }
229
- const validation = validate(call.input);
230
- if (!validation.ok) {
231
- await handle.append({
232
- type: "tool.rejected",
233
- payload: {
234
- callId: call.id,
235
- name: call.name,
236
- code: "schema_mismatch",
237
- reason: `input does not match the schema of ${call.name}: ${validation.reason}`,
238
- },
239
- });
240
- return;
241
- }
242
- await handle.append({
243
- type: "tool.called",
244
- payload: { callId: call.id, provider: RUNTIME_PROVIDER_ID, name: call.name, input: call.input },
245
- });
246
- let result;
247
- try {
248
- result = await runSessionTool(runtime, handle, model, options, signal, call.name, call.input);
249
- }
250
- catch (err) {
251
- result = {
252
- content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
253
- isError: true,
254
- };
255
- }
256
- await handle.append({
257
- type: "tool.completed",
258
- payload: { callId: call.id, content: result.content, isError: result.isError ?? false },
259
- });
260
- }
261
- async function runSessionTool(runtime, handle, model, options, signal, name, input) {
262
- switch (name) {
263
- case "work_run": {
264
- const type = typeof input.type === "string" && input.type !== "" ? input.type : "request";
265
- if (type === SESSION_WORK_TYPE) {
266
- return {
249
+ /**
250
+ * One tool call of the model, through the runtime. Keeps track of the work the model is on,
251
+ * asks the person when the runtime says a question is pending, and mirrors the call and its
252
+ * result into the session's projection.
253
+ */
254
+ async function callTool(call, signal) {
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));
263
+ // The loop drives these itself; a model that calls them is refused before the runtime sees it.
264
+ const refusal = LOOP_ONLY_TOOLS.has(call.name)
265
+ ? `${call.name} is the loop's own; it is not a tool for the model`
266
+ : !task && (call.name === "work_complete" || call.name === "work_fail")
267
+ ? `${call.name} needs a work of its own: no work is open; start one with work_create`
268
+ : undefined;
269
+ if (refusal) {
270
+ await finish(call.id, {
271
+ content: [{ type: "text", text: refusal }],
272
+ isError: true,
273
+ text: "",
274
+ });
275
+ return "done";
276
+ }
277
+ const input = call.name === "work_create" && call.input && typeof call.input === "object"
278
+ ? { ...call.input, parent: id, agent_name: agentName }
279
+ : call.input;
280
+ let result;
281
+ try {
282
+ result = await client.call(call.name, input, signal);
283
+ }
284
+ catch (err) {
285
+ result = {
286
+ content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
287
+ isError: true,
288
+ text: "",
289
+ };
290
+ }
291
+ task?.callIds.add(call.id);
292
+ const data = result.isError
293
+ ? undefined
294
+ : jsonOf(result);
295
+ if (!result.isError &&
296
+ (call.name === "work_create" || call.name === "work_select") &&
297
+ data?.id) {
298
+ const workId = data.id;
299
+ const history = data.history;
300
+ task = {
301
+ id: workId,
302
+ modelCalls: typeof history?.modelCalls === "number" ? history.modelCalls : 0,
303
+ callIds: new Set([call.id]),
304
+ };
305
+ candidate = undefined;
306
+ await options.onEvent?.(workId, local("work.status_changed", {
307
+ from: "queued",
308
+ to: String(data.status ?? "in_progress"),
309
+ reason: call.name,
310
+ }));
311
+ // A selected work that waits for an answer gets it now, oldest question first.
312
+ if (call.name === "work_select" && data.status === "waiting_input") {
313
+ const answered = await answerPending(workId, signal);
314
+ if (answered === "withdrawn") {
315
+ await finish(call.id, result);
316
+ return "withdrawn";
317
+ }
318
+ if (answered.length > 0) {
319
+ result = {
320
+ ...result,
321
+ text: "",
322
+ content: [
323
+ ...result.content,
324
+ { type: "text", text: `answers recorded: ${JSON.stringify(answered)}` },
325
+ ],
326
+ };
327
+ }
328
+ }
329
+ }
330
+ if (!result.isError && call.name === ASK_USER_TOOL_NAME && data?.pending === true && task) {
331
+ const asked = task.id;
332
+ const question = String(data.question ?? "");
333
+ if (!options.onInput) {
334
+ result = {
335
+ ...result,
336
+ text: "",
267
337
  content: [
268
338
  {
269
339
  type: "text",
270
- text: `type "${SESSION_WORK_TYPE}" is reserved for conversations; use another label, such as request`,
340
+ text: "the work waits for the person's answer; it can be resumed later",
271
341
  },
272
342
  ],
273
- isError: true,
274
343
  };
275
344
  }
276
- const agentName = (await handle.current()).agentName;
277
- const child = await runtime.works.create({
278
- objective: String(input.objective),
279
- principal: runtime.config.principal.id,
280
- profession: runtime.config.profession.id,
281
- type,
282
- parent: handle.id,
283
- ...(agentName !== undefined && { agentName }),
284
- });
285
- const done = await runWork(runtime, child.id, {
286
- model,
287
- ...(options.onInput && {
288
- onInput: (q) => options.onInput?.(child.id, q),
289
- }),
290
- ...(options.onWorkEvent && {
291
- onEvent: (e) => options.onWorkEvent?.(child.id, e),
292
- }),
293
- ...(signal && { signal }),
294
- });
295
- return {
296
- content: [
297
- { type: "json", value: await describeWork(runtime, done, signal?.aborted === true) },
298
- ],
345
+ else {
346
+ let answer;
347
+ try {
348
+ answer = await options.onInput(asked, question);
349
+ }
350
+ catch {
351
+ // The person took the question back: the work stays waiting_input.
352
+ await finish(call.id, {
353
+ content: [{ type: "text", text: "the person withdrew the question; the work waits" }],
354
+ isError: true,
355
+ text: "",
356
+ });
357
+ return "withdrawn";
358
+ }
359
+ const answered = await client.call("work_answer", { call_id: data.call_id, answer }, signal);
360
+ result = answered.isError
361
+ ? answered
362
+ : { content: [{ type: "text", text: answer }], isError: false, text: answer };
363
+ }
364
+ }
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 }),
299
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";
300
470
  }
301
- case "work_list": {
302
- const { works } = await runtime.works.list();
303
- const recent = works
304
- .filter((w) => w.type !== SESSION_WORK_TYPE)
305
- .sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0))
306
- .slice(0, 20)
307
- .map((w) => ({
308
- id: w.id,
309
- status: w.status,
310
- type: w.type,
311
- objective: w.objective,
312
- createdAt: w.createdAt,
313
- }));
314
- return { content: [{ type: "json", value: { works: recent } }] };
471
+ await finish(call.id, result);
472
+ if (!result.isError && (call.name === "work_complete" || call.name === "work_fail") && task) {
473
+ const closed = task;
474
+ task = undefined;
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 });
484
+ await options.onEvent?.(closed.id, local(call.name === "work_complete" ? "work.completed" : "work.failed", call.name === "work_complete"
485
+ ? { summary: String(call.input?.summary ?? "") }
486
+ : { reason: String(call.input?.reason ?? ""), detail: "" }));
315
487
  }
316
- case "work_show": {
317
- let id;
488
+ return "done";
489
+ }
490
+ /** Asks the person every question the work still waits on and records the answers. */
491
+ async function answerPending(workId, signal) {
492
+ if (!options.onInput)
493
+ return [];
494
+ const got = await client.call("work_get", { id: workId, history: true }, signal);
495
+ const history = jsonOf(got)?.history;
496
+ const answers = [];
497
+ for (const { callId, question } of history?.pending ?? []) {
498
+ let answer;
318
499
  try {
319
- id = parseWorkId(String(input.id));
500
+ answer = await options.onInput(workId, question);
320
501
  }
321
502
  catch {
322
- return {
323
- content: [{ type: "text", text: `"${String(input.id)}" is not a work id` }],
324
- isError: true,
325
- };
503
+ return "withdrawn";
326
504
  }
327
- const work = await runtime.works.get(id);
328
- return { content: [{ type: "json", value: await describeWork(runtime, work, false) }] };
505
+ const recorded = await client.call("work_answer", { call_id: callId, answer }, signal);
506
+ if (recorded.isError)
507
+ throw new Error(recorded.text);
508
+ answers.push({ question, answer });
329
509
  }
330
- default:
331
- return { content: [{ type: "text", text: `no tool named "${name}"` }], isError: true };
510
+ return answers;
332
511
  }
333
- }
334
- /** A work as the session's model needs to see it: outcome, cost, and who acts next. */
335
- async function describeWork(runtime, work, interrupted) {
336
- const events = await runtime.works.events(work.id);
337
- const usage = {
338
- modelCalls: 0,
339
- toolCalls: countToolCalls(events),
340
- inputTokens: 0,
341
- outputTokens: 0,
342
- };
343
- for (const event of events) {
344
- if (event.type === "model.requested")
345
- usage.modelCalls += 1;
346
- if (event.type === "usage.recorded") {
347
- const { payload } = event;
348
- if (payload.kind === "model_inference") {
349
- usage.inputTokens += payload.usage.inputTokens;
350
- usage.outputTokens += payload.usage.outputTokens;
351
- }
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) {
522
+ const event = local("tool.completed", {
523
+ callId,
524
+ content: result.content,
525
+ isError: result.isError,
526
+ });
527
+ events.push(event);
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);
530
+ }
531
+ /** Once a work is closed, only its summary stays in the conversation: the tool results are folded away. */
532
+ function foldAway(closed, closingCallId) {
533
+ for (const event of events) {
534
+ if (event.type !== "tool.completed")
535
+ continue;
536
+ const payload = event.payload;
537
+ if (!closed.callIds.has(payload.callId) || payload.callId === closingCallId)
538
+ continue;
539
+ payload.content = [
540
+ {
541
+ type: "text",
542
+ text: `(この結果は Work ${closed.id} を閉じたので省略。要点は work_complete の summary にある)`,
543
+ },
544
+ ];
352
545
  }
353
546
  }
354
- const question = work.status === "waiting_input" ? pendingQuestions(events).map((q) => q.question) : [];
355
547
  return {
356
- id: work.id,
357
- status: work.status,
358
- ...(work.outcome && { summary: work.outcome.summary, artifacts: work.outcome.artifacts }),
359
- ...(work.failure && { failure: work.failure }),
360
- ...(question.length > 0 && { waitingFor: question }),
361
- ...(interrupted &&
362
- work.status === "in_progress" && {
363
- interrupted: "the person stopped this work; it can be resumed",
364
- }),
365
- nextActor: isTerminal(work.status)
366
- ? "nobody"
367
- : work.status === "waiting_input"
368
- ? "person"
369
- : "model",
370
- usage,
548
+ id,
549
+ agentName,
550
+ async turn(text, turnOptions = {}) {
551
+ held = undefined;
552
+ events.push(local("human.message", { text }));
553
+ await record(id, "human.message", { text });
554
+ if (candidate) {
555
+ const note = `候補の Work: ${candidate.id}(status: ${candidate.status}、objective: ${candidate.objective})。この依頼がその objective に沿うなら work_select で続ける。沿わなければ続けず、その旨を伝えて新しい Work を作るか work_list で探し直す。`;
556
+ events.push(local("prompt.expanded", { name: "work resume", source: "builtin", text: note }));
557
+ await record(id, "prompt.expanded", { name: "work resume", source: "builtin", text: note });
558
+ }
559
+ try {
560
+ const result = await runTurn(turnOptions.signal);
561
+ return task ? { ...result, work: task.id } : result;
562
+ }
563
+ finally {
564
+ // Whatever the turn did, the next one starts from the conversation: a work it left open
565
+ // stays as it is and comes back as a candidate through select; a declined candidate is dropped.
566
+ candidate = undefined;
567
+ if (task) {
568
+ task = undefined;
569
+ await client.call("work_select", { id }).catch(() => undefined);
570
+ }
571
+ }
572
+ },
573
+ async select(workId) {
574
+ const got = await client.call("work_get", { id: workId });
575
+ if (got.isError)
576
+ throw new Error(got.text);
577
+ const work = jsonOf(got);
578
+ if (isTerminal(work.status))
579
+ throw new Error(`${work.id} は ${work.status} で、続けられません`);
580
+ candidate = { id: work.id, objective: work.objective, status: work.status };
581
+ return work;
582
+ },
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
+ },
648
+ async close() {
649
+ const selected = await client.call("work_select", { id });
650
+ if (selected.isError) {
651
+ const got = await client.call("work_get", { id });
652
+ return jsonOf(got);
653
+ }
654
+ const closed = await client.call("work_complete", { summary: "会話を終了" });
655
+ if (closed.isError)
656
+ throw new Error(closed.text);
657
+ return jsonOf(closed);
658
+ },
371
659
  };
372
660
  }
661
+ /** The names of the sessions still open, so two people talking at once do not get the same one. */
662
+ async function namesInUse(client) {
663
+ const listed = await client.call("work_list", {});
664
+ if (listed.isError)
665
+ return [];
666
+ const { works } = jsonOf(listed);
667
+ return works
668
+ .filter((w) => w.type === SESSION_WORK_TYPE && !isTerminal(w.status))
669
+ .flatMap((w) => (w.agentName ? [w.agentName] : []));
670
+ }
373
671
  function textOf(content) {
374
672
  return content
375
673
  .filter((p) => p.type === "text")
@@ -377,18 +675,3 @@ function textOf(content) {
377
675
  .join("\n")
378
676
  .trim();
379
677
  }
380
- function observed(handle, onEvent) {
381
- return {
382
- ...handle,
383
- async append(event) {
384
- const recorded = await handle.append(event);
385
- await onEvent(recorded);
386
- return recorded;
387
- },
388
- async transition(to, reason) {
389
- const recorded = await handle.transition(to, reason);
390
- await onEvent(recorded);
391
- return recorded;
392
- },
393
- };
394
- }