@openshain/agent 0.2.0 → 0.3.1
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/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 +24 -15
- package/dist/session.js +376 -342
- package/package.json +5 -3
- package/src/client.ts +73 -0
- package/src/index.ts +2 -12
- package/src/session.ts +429 -399
- 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,88 +1,43 @@
|
|
|
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
|
-
type ToolContent,
|
|
16
16
|
type ToolDefinition,
|
|
17
17
|
type Work,
|
|
18
|
-
type WorkHandle,
|
|
19
18
|
type WorkId,
|
|
20
19
|
} from "@openshain/core";
|
|
21
|
-
import {
|
|
20
|
+
import { type ClientResult, jsonOf, type RuntimeClient } from "./client.ts";
|
|
22
21
|
import { pickAgentName } from "./names.ts";
|
|
23
22
|
|
|
24
|
-
/** How much one turn of
|
|
25
|
-
export const TURN_LIMITS = { modelCalls:
|
|
23
|
+
/** How much one turn of the conversation may do before it stops and the person is told. */
|
|
24
|
+
export const TURN_LIMITS = { modelCalls: 25, toolCalls: 40 } as const;
|
|
26
25
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
{
|
|
30
|
-
name: "work_run",
|
|
31
|
-
description:
|
|
32
|
-
"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.",
|
|
33
|
-
inputSchema: {
|
|
34
|
-
type: "object",
|
|
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
|
-
},
|
|
68
|
-
]);
|
|
69
|
-
|
|
70
|
-
const validators = new Map(
|
|
71
|
-
SESSION_TOOLS.map((tool) => [tool.name, compileInputValidator(tool.inputSchema)] as const),
|
|
72
|
-
);
|
|
26
|
+
/** The tools of the runtime that the loop itself drives; the model never sees them. */
|
|
27
|
+
const LOOP_ONLY_TOOLS: ReadonlySet<string> = new Set(["work_record", "work_answer"]);
|
|
73
28
|
|
|
74
29
|
const ROLE =
|
|
75
|
-
"あなたはこの会社の社員エージェントとして、受付の役で、この人と話す。作業が要るときは
|
|
30
|
+
"あなたはこの会社の社員エージェントとして、受付の役で、この人と話す。作業が要るときは work_create で Work を作り(objective は人の言葉で書き、会話で分かった前提を添える)、その Work の中で Tool を呼び、終わったら work_complete で summary を人の言葉で書いて閉じる。会話の中では Tool を呼べないので、ファイルの中身を見ないと答えられない質問も Work を作って調べる。件数や金額は Tool が返した値をそのまま書き、計算し直さない。/work resume で候補として示された Work は、人の依頼がその objective に沿うときだけ work_select で続ける。沿わなければ続けず、その旨を伝えて新しい Work を作るか work_list で探し直す。返答は端末の画面に出るので、Markdown の記法や絵文字は使わず、短い文で書く。過去の作業は work_list と work_get で答える。";
|
|
76
31
|
|
|
77
32
|
export interface SessionOptions {
|
|
78
|
-
/**
|
|
79
|
-
model
|
|
33
|
+
/** The model the conversation runs on. The client owns it; the runtime never calls one. */
|
|
34
|
+
model: ModelProvider;
|
|
35
|
+
/** The workspace's configuration, for the prompt, the limits and the provider options. */
|
|
36
|
+
config: Pick<Config, "company" | "principal" | "profession" | "limits" | "model" | "debug">;
|
|
80
37
|
/** The name the agent goes by. Picked from the list, avoiding open sessions' names, when omitted. */
|
|
81
38
|
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>;
|
|
39
|
+
/** Called for every event the session records or sees: the session's own and the works'. A returned promise is awaited. */
|
|
40
|
+
onEvent?: (workId: WorkId, event: AnyEvent) => void | Promise<void>;
|
|
86
41
|
/** Answers a question a work asks the person. Without it, the work waits for input. */
|
|
87
42
|
onInput?: (workId: WorkId, question: string) => Promise<string>;
|
|
88
43
|
}
|
|
@@ -95,6 +50,8 @@ export interface TurnResult {
|
|
|
95
50
|
/** Why the turn ended before the model replied, if it did. */
|
|
96
51
|
stopped?: TurnStop;
|
|
97
52
|
detail?: string;
|
|
53
|
+
/** The work the turn left open, when it stopped inside one. It can be continued with select. */
|
|
54
|
+
work?: WorkId;
|
|
98
55
|
}
|
|
99
56
|
|
|
100
57
|
export interface Session {
|
|
@@ -103,383 +60,475 @@ export interface Session {
|
|
|
103
60
|
readonly agentName: string;
|
|
104
61
|
/** Records what the person said and runs the model until it replies or the turn stops. */
|
|
105
62
|
turn(text: string, options?: { signal?: AbortSignal }): Promise<TurnResult>;
|
|
106
|
-
/**
|
|
63
|
+
/** Names a stopped work as the candidate for the next request. The model decides whether to continue it. */
|
|
64
|
+
select(workId: WorkId): Promise<Work>;
|
|
65
|
+
/** The work the model is on right now, if any. */
|
|
66
|
+
currentWork(): WorkId | undefined;
|
|
67
|
+
/** Ends the conversation. The record stays; a work left in progress stays in progress. */
|
|
107
68
|
close(): Promise<Work>;
|
|
108
69
|
}
|
|
109
70
|
|
|
110
|
-
|
|
71
|
+
interface TaskState {
|
|
72
|
+
id: WorkId;
|
|
73
|
+
modelCalls: number;
|
|
74
|
+
/** Ids of the calls the model made inside this work, to fold their results away when it closes. */
|
|
75
|
+
callIds: Set<string>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Opens a conversation, recorded as a work of type "session", between the person and the model.
|
|
80
|
+
* The loop is a client of the runtime: it creates works, calls tools and closes works through
|
|
81
|
+
* the same MCP tools any other agent uses, and records its own model calls with work_record.
|
|
82
|
+
*/
|
|
111
83
|
export async function createSession(
|
|
112
|
-
|
|
113
|
-
options: SessionOptions
|
|
84
|
+
client: RuntimeClient,
|
|
85
|
+
options: SessionOptions,
|
|
114
86
|
): Promise<Session> {
|
|
115
|
-
const model = options
|
|
116
|
-
const { principal, profession } = runtime.config;
|
|
87
|
+
const { model, config } = options;
|
|
117
88
|
const agentName =
|
|
118
|
-
options.agentName ?? pickAgentName(
|
|
119
|
-
const
|
|
89
|
+
options.agentName ?? pickAgentName(config.company.language, await namesInUse(client));
|
|
90
|
+
const opened = await client.call("work_create", {
|
|
120
91
|
objective: "会話",
|
|
121
|
-
principal: principal.id,
|
|
122
|
-
profession: profession.id,
|
|
123
92
|
type: SESSION_WORK_TYPE,
|
|
124
|
-
agentName,
|
|
93
|
+
agent_name: agentName,
|
|
125
94
|
});
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
95
|
+
if (opened.isError) throw new Error(`could not open a session: ${opened.text}`);
|
|
96
|
+
const session = jsonOf(opened) as Work;
|
|
97
|
+
const id = session.id;
|
|
129
98
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
return handle.current();
|
|
147
|
-
}),
|
|
99
|
+
/** The session's events as the projection needs them, kept in memory; the runtime holds the record. */
|
|
100
|
+
const events: AnyEvent[] = [];
|
|
101
|
+
let seq = 0;
|
|
102
|
+
const local = <T extends EventType>(type: T, payload: EventPayloads[T]): Event<T> => {
|
|
103
|
+
const now = new Date().toISOString();
|
|
104
|
+
seq += 1;
|
|
105
|
+
return {
|
|
106
|
+
v: 1,
|
|
107
|
+
id: newEventId(),
|
|
108
|
+
workId: id,
|
|
109
|
+
seq,
|
|
110
|
+
type,
|
|
111
|
+
payload,
|
|
112
|
+
occurredAt: now,
|
|
113
|
+
recordedAt: now,
|
|
114
|
+
} as Event<T>;
|
|
148
115
|
};
|
|
149
|
-
|
|
116
|
+
events.push(
|
|
117
|
+
local("work.created", {
|
|
118
|
+
objective: "会話",
|
|
119
|
+
principal: config.principal.id,
|
|
120
|
+
profession: config.profession.id,
|
|
121
|
+
type: SESSION_WORK_TYPE,
|
|
122
|
+
agentName,
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
let task: TaskState | undefined;
|
|
126
|
+
let candidate: { id: WorkId; objective: string; status: string } | undefined;
|
|
150
127
|
|
|
151
|
-
/**
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
128
|
+
/** Records one of the client's own events on a work through the runtime, and reports it. */
|
|
129
|
+
const record = async <T extends EventType>(
|
|
130
|
+
workId: WorkId,
|
|
131
|
+
type: T,
|
|
132
|
+
payload: EventPayloads[T],
|
|
133
|
+
): Promise<void> => {
|
|
134
|
+
const event = local(type, payload);
|
|
135
|
+
const file = eventToFile({ ...event, workId });
|
|
136
|
+
const result = await client.call("work_record", {
|
|
137
|
+
work_id: workId,
|
|
138
|
+
type,
|
|
139
|
+
payload: file.payload,
|
|
140
|
+
});
|
|
141
|
+
if (result.isError) throw new Error(`work_record failed: ${result.text}`);
|
|
142
|
+
await options.onEvent?.(workId, { ...event, workId });
|
|
143
|
+
};
|
|
144
|
+
/** Records a model event on the session and, while a work is open, on that work as well. */
|
|
145
|
+
const recordModelEvent = async <T extends EventType>(type: T, payload: EventPayloads[T]) => {
|
|
146
|
+
events.push(local(type, payload));
|
|
147
|
+
await record(id, type, payload);
|
|
148
|
+
if (task) await record(task.id, type, payload);
|
|
149
|
+
};
|
|
158
150
|
|
|
159
|
-
async
|
|
160
|
-
|
|
161
|
-
id: WorkId,
|
|
162
|
-
options: SessionOptions,
|
|
163
|
-
fn: (handle: WorkHandle) => Promise<T>,
|
|
164
|
-
): Promise<T> {
|
|
165
|
-
const opened = await runtime.works.open(id);
|
|
166
|
-
const handle = options.onEvent ? observed(opened, options.onEvent) : opened;
|
|
167
|
-
try {
|
|
168
|
-
return await fn(handle);
|
|
169
|
-
} finally {
|
|
170
|
-
await handle.close();
|
|
171
|
-
}
|
|
172
|
-
}
|
|
151
|
+
const describedTools = async (): Promise<ToolDefinition[]> =>
|
|
152
|
+
(await client.listTools()).filter((t) => !LOOP_ONLY_TOOLS.has(t.name));
|
|
173
153
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
handle: WorkHandle,
|
|
177
|
-
model: ModelProvider,
|
|
178
|
-
options: SessionOptions,
|
|
179
|
-
signal: AbortSignal | undefined,
|
|
180
|
-
): Promise<TurnResult> {
|
|
181
|
-
const description = model.describe();
|
|
182
|
-
const config = {
|
|
183
|
-
...runtime.config,
|
|
154
|
+
const promptConfig = {
|
|
155
|
+
...config,
|
|
184
156
|
profession: {
|
|
185
|
-
...
|
|
186
|
-
instructions: `${
|
|
157
|
+
...config.profession,
|
|
158
|
+
instructions: `${config.profession.instructions.trim()}\n\n${ROLE}`,
|
|
187
159
|
},
|
|
188
160
|
};
|
|
189
|
-
const turnStart = (await handle.events()).length;
|
|
190
|
-
const tools = [...SESSION_TOOLS];
|
|
191
|
-
let modelCalls = 0;
|
|
192
|
-
let toolCalls = 0;
|
|
193
161
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
162
|
+
async function runTurn(signal: AbortSignal | undefined): Promise<TurnResult> {
|
|
163
|
+
const description = model.describe();
|
|
164
|
+
const tools = await describedTools();
|
|
165
|
+
let modelCalls = 0;
|
|
166
|
+
let toolCalls = 0;
|
|
167
|
+
for (;;) {
|
|
168
|
+
if (signal?.aborted) return { reply: "", stopped: "aborted" };
|
|
169
|
+
if (modelCalls >= TURN_LIMITS.modelCalls) {
|
|
170
|
+
return {
|
|
171
|
+
reply: "",
|
|
172
|
+
stopped: "turn_limit",
|
|
173
|
+
detail: `model calls in one turn (${TURN_LIMITS.modelCalls})`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const projection = buildProjection({
|
|
177
|
+
events,
|
|
178
|
+
config: promptConfig,
|
|
179
|
+
tools,
|
|
180
|
+
providerId: model.id,
|
|
181
|
+
budget: {
|
|
182
|
+
modelCallsLeft: TURN_LIMITS.modelCalls - modelCalls,
|
|
183
|
+
toolCallsLeft: TURN_LIMITS.toolCalls - toolCalls,
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
if (task && task.modelCalls >= config.limits.maxModelCalls) {
|
|
187
|
+
await callTool(
|
|
188
|
+
{
|
|
189
|
+
id: `call_limit_${task.id}`,
|
|
190
|
+
name: "work_fail",
|
|
191
|
+
input: {
|
|
192
|
+
reason: "limit_reached",
|
|
193
|
+
detail: `${config.limits.maxModelCalls} model calls`,
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
signal,
|
|
197
|
+
);
|
|
198
|
+
return {
|
|
199
|
+
reply: "",
|
|
200
|
+
stopped: "turn_limit",
|
|
201
|
+
detail: `model calls in one work (${config.limits.maxModelCalls})`,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
await recordModelEvent("model.requested", {
|
|
217
205
|
provider: model.id,
|
|
218
206
|
model: description.model,
|
|
219
207
|
messageCount: projection.messages.length,
|
|
220
208
|
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
209
|
});
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
210
|
+
modelCalls += 1;
|
|
211
|
+
if (task) task.modelCalls += 1;
|
|
212
|
+
|
|
213
|
+
let response: ModelResponse;
|
|
214
|
+
try {
|
|
215
|
+
response = await model.generate(
|
|
216
|
+
{
|
|
217
|
+
system: projection.system,
|
|
218
|
+
messages: projection.messages,
|
|
219
|
+
tools: projection.tools,
|
|
220
|
+
maxOutputTokens: config.limits.maxOutputTokens,
|
|
221
|
+
budget: projection.budget,
|
|
222
|
+
stableMessages: projection.messages.length - 1,
|
|
223
|
+
...(config.model?.options && { providerOptions: config.model.options }),
|
|
224
|
+
},
|
|
225
|
+
signal,
|
|
226
|
+
);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
229
|
+
await recordModelEvent("model.failed", { code: "model_error", message });
|
|
230
|
+
return { reply: "", stopped: signal?.aborted ? "aborted" : "model_error", detail: message };
|
|
231
|
+
}
|
|
232
|
+
await recordModelEvent("model.completed", {
|
|
250
233
|
stopReason: response.stopReason,
|
|
251
234
|
content: response.message.content,
|
|
252
|
-
...(
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
await handle.append({
|
|
256
|
-
type: "usage.recorded",
|
|
257
|
-
payload: {
|
|
235
|
+
...(config.debug?.persistRaw && response.raw !== undefined && { raw: response.raw }),
|
|
236
|
+
});
|
|
237
|
+
await recordModelEvent("usage.recorded", {
|
|
258
238
|
kind: "model_inference",
|
|
259
239
|
provider: model.id,
|
|
260
240
|
model: description.model,
|
|
261
241
|
usage: response.usage,
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
242
|
+
});
|
|
243
|
+
const text = textOf(response.message.content);
|
|
244
|
+
switch (response.stopReason) {
|
|
245
|
+
case "end_turn":
|
|
246
|
+
return { reply: text };
|
|
247
|
+
case "tool_call": {
|
|
248
|
+
const calls = response.message.content.filter((p) => p.type === "tool_call");
|
|
249
|
+
for (const call of calls) {
|
|
250
|
+
if (signal?.aborted) return { reply: text, stopped: "aborted" };
|
|
251
|
+
if (toolCalls >= TURN_LIMITS.toolCalls) {
|
|
252
|
+
return {
|
|
253
|
+
reply: text,
|
|
254
|
+
stopped: "turn_limit",
|
|
255
|
+
detail: `tool calls in one turn (${TURN_LIMITS.toolCalls})`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
toolCalls += 1;
|
|
259
|
+
const outcome = await callTool(call, signal);
|
|
260
|
+
if (outcome === "withdrawn") return { reply: text, stopped: "aborted" };
|
|
279
261
|
}
|
|
280
|
-
|
|
281
|
-
await callSessionTool(runtime, handle, model, options, signal, call);
|
|
262
|
+
break;
|
|
282
263
|
}
|
|
283
|
-
|
|
264
|
+
case "max_tokens":
|
|
265
|
+
return { reply: text, stopped: "max_tokens" };
|
|
266
|
+
case "refusal":
|
|
267
|
+
return { reply: text, stopped: "refusal" };
|
|
268
|
+
default:
|
|
269
|
+
return {
|
|
270
|
+
reply: text,
|
|
271
|
+
stopped: "model_error",
|
|
272
|
+
detail: `unexpected stop reason "${response.stopReason}"`,
|
|
273
|
+
};
|
|
284
274
|
}
|
|
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
275
|
}
|
|
296
|
-
// Nothing of the turn's own events is needed below; the projection rebuilds from the log.
|
|
297
|
-
void turnStart;
|
|
298
276
|
}
|
|
299
|
-
}
|
|
300
277
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
},
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if (!validation.ok) {
|
|
324
|
-
await handle.append({
|
|
325
|
-
type: "tool.rejected",
|
|
326
|
-
payload: {
|
|
278
|
+
/**
|
|
279
|
+
* One tool call of the model, through the runtime. Keeps track of the work the model is on,
|
|
280
|
+
* asks the person when the runtime says a question is pending, and mirrors the call and its
|
|
281
|
+
* result into the session's projection.
|
|
282
|
+
*/
|
|
283
|
+
async function callTool(
|
|
284
|
+
call: { id: string; name: string; input: unknown },
|
|
285
|
+
signal: AbortSignal | undefined,
|
|
286
|
+
): Promise<"done" | "withdrawn"> {
|
|
287
|
+
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
|
+
events.push(
|
|
299
|
+
local("tool.called", {
|
|
327
300
|
callId: call.id,
|
|
301
|
+
provider: "runtime",
|
|
328
302
|
name: call.name,
|
|
329
|
-
|
|
330
|
-
|
|
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>,
|
|
303
|
+
input: call.input,
|
|
304
|
+
}),
|
|
349
305
|
);
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
}
|
|
306
|
+
await options.onEvent?.(workId, events.at(-1) as AnyEvent);
|
|
307
|
+
const input =
|
|
308
|
+
call.name === "work_create" && call.input && typeof call.input === "object"
|
|
309
|
+
? { ...(call.input as Record<string, unknown>), parent: id, agent_name: agentName }
|
|
310
|
+
: call.input;
|
|
311
|
+
let result: ClientResult;
|
|
312
|
+
try {
|
|
313
|
+
result = await client.call(call.name, input, signal);
|
|
314
|
+
} catch (err) {
|
|
315
|
+
result = {
|
|
316
|
+
content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
|
|
317
|
+
isError: true,
|
|
318
|
+
text: "",
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
task?.callIds.add(call.id);
|
|
322
|
+
const data = result.isError
|
|
323
|
+
? undefined
|
|
324
|
+
: (jsonOf(result) as Record<string, unknown> | undefined);
|
|
361
325
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
326
|
+
if (
|
|
327
|
+
!result.isError &&
|
|
328
|
+
(call.name === "work_create" || call.name === "work_select") &&
|
|
329
|
+
data?.id
|
|
330
|
+
) {
|
|
331
|
+
const workId = data.id as WorkId;
|
|
332
|
+
const history = (data as { history?: { modelCalls?: number } }).history;
|
|
333
|
+
task = {
|
|
334
|
+
id: workId,
|
|
335
|
+
modelCalls: typeof history?.modelCalls === "number" ? history.modelCalls : 0,
|
|
336
|
+
callIds: new Set([call.id]),
|
|
337
|
+
};
|
|
338
|
+
candidate = undefined;
|
|
339
|
+
await options.onEvent?.(
|
|
340
|
+
workId,
|
|
341
|
+
local("work.status_changed", {
|
|
342
|
+
from: "queued",
|
|
343
|
+
to: String(data.status ?? "in_progress"),
|
|
344
|
+
reason: call.name,
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
// A selected work that waits for an answer gets it now, oldest question first.
|
|
348
|
+
if (call.name === "work_select" && data.status === "waiting_input") {
|
|
349
|
+
const answered = await answerPending(workId, signal);
|
|
350
|
+
if (answered === "withdrawn") {
|
|
351
|
+
finish(call.id, result);
|
|
352
|
+
return "withdrawn";
|
|
353
|
+
}
|
|
354
|
+
if (answered.length > 0) {
|
|
355
|
+
result = {
|
|
356
|
+
...result,
|
|
357
|
+
text: "",
|
|
358
|
+
content: [
|
|
359
|
+
...result.content,
|
|
360
|
+
{ type: "text", text: `answers recorded: ${JSON.stringify(answered)}` },
|
|
361
|
+
],
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (!result.isError && call.name === ASK_USER_TOOL_NAME && data?.pending === true && task) {
|
|
367
|
+
const asked = task.id;
|
|
368
|
+
const question = String(data.question ?? "");
|
|
369
|
+
if (!options.onInput) {
|
|
370
|
+
result = {
|
|
371
|
+
...result,
|
|
372
|
+
text: "",
|
|
376
373
|
content: [
|
|
377
374
|
{
|
|
378
375
|
type: "text",
|
|
379
|
-
text:
|
|
376
|
+
text: "the work waits for the person's answer; it can be resumed later",
|
|
380
377
|
},
|
|
381
378
|
],
|
|
382
|
-
isError: true,
|
|
383
379
|
};
|
|
380
|
+
} else {
|
|
381
|
+
let answer: string;
|
|
382
|
+
try {
|
|
383
|
+
answer = await options.onInput(asked, question);
|
|
384
|
+
} catch {
|
|
385
|
+
// The person took the question back: the work stays waiting_input.
|
|
386
|
+
finish(call.id, {
|
|
387
|
+
content: [{ type: "text", text: "the person withdrew the question; the work waits" }],
|
|
388
|
+
isError: true,
|
|
389
|
+
text: "",
|
|
390
|
+
});
|
|
391
|
+
return "withdrawn";
|
|
392
|
+
}
|
|
393
|
+
const answered = await client.call(
|
|
394
|
+
"work_answer",
|
|
395
|
+
{ call_id: data.call_id, answer },
|
|
396
|
+
signal,
|
|
397
|
+
);
|
|
398
|
+
result = answered.isError
|
|
399
|
+
? answered
|
|
400
|
+
: { content: [{ type: "text", text: answer }], isError: false, text: answer };
|
|
384
401
|
}
|
|
385
|
-
const agentName = (await handle.current()).agentName;
|
|
386
|
-
const child = await runtime.works.create({
|
|
387
|
-
objective: String(input.objective),
|
|
388
|
-
principal: runtime.config.principal.id,
|
|
389
|
-
profession: runtime.config.profession.id,
|
|
390
|
-
type,
|
|
391
|
-
parent: handle.id,
|
|
392
|
-
...(agentName !== undefined && { agentName }),
|
|
393
|
-
});
|
|
394
|
-
const done = await runWork(runtime, child.id, {
|
|
395
|
-
model,
|
|
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
|
-
],
|
|
408
|
-
};
|
|
409
402
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
.
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
403
|
+
finish(call.id, result);
|
|
404
|
+
if (!result.isError && (call.name === "work_complete" || call.name === "work_fail") && task) {
|
|
405
|
+
const closed = task;
|
|
406
|
+
task = undefined;
|
|
407
|
+
foldAway(closed, call.id);
|
|
408
|
+
await options.onEvent?.(
|
|
409
|
+
closed.id,
|
|
410
|
+
local(
|
|
411
|
+
call.name === "work_complete" ? "work.completed" : "work.failed",
|
|
412
|
+
call.name === "work_complete"
|
|
413
|
+
? { summary: String((call.input as { summary?: unknown })?.summary ?? "") }
|
|
414
|
+
: { reason: String((call.input as { reason?: unknown })?.reason ?? ""), detail: "" },
|
|
415
|
+
) as AnyEvent,
|
|
416
|
+
);
|
|
424
417
|
}
|
|
425
|
-
|
|
426
|
-
|
|
418
|
+
return "done";
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Asks the person every question the work still waits on and records the answers. */
|
|
422
|
+
async function answerPending(
|
|
423
|
+
workId: WorkId,
|
|
424
|
+
signal: AbortSignal | undefined,
|
|
425
|
+
): Promise<{ question: string; answer: string }[] | "withdrawn"> {
|
|
426
|
+
if (!options.onInput) return [];
|
|
427
|
+
const got = await client.call("work_get", { id: workId, history: true }, signal);
|
|
428
|
+
const history = (
|
|
429
|
+
jsonOf(got) as { history?: { pending?: { callId: string; question: string }[] } }
|
|
430
|
+
)?.history;
|
|
431
|
+
const answers: { question: string; answer: string }[] = [];
|
|
432
|
+
for (const { callId, question } of history?.pending ?? []) {
|
|
433
|
+
let answer: string;
|
|
427
434
|
try {
|
|
428
|
-
|
|
435
|
+
answer = await options.onInput(workId, question);
|
|
429
436
|
} catch {
|
|
430
|
-
return
|
|
431
|
-
content: [{ type: "text", text: `"${String(input.id)}" is not a work id` }],
|
|
432
|
-
isError: true,
|
|
433
|
-
};
|
|
437
|
+
return "withdrawn";
|
|
434
438
|
}
|
|
435
|
-
const
|
|
436
|
-
|
|
439
|
+
const recorded = await client.call("work_answer", { call_id: callId, answer }, signal);
|
|
440
|
+
if (recorded.isError) throw new Error(recorded.text);
|
|
441
|
+
answers.push({ question, answer });
|
|
437
442
|
}
|
|
438
|
-
|
|
439
|
-
return { content: [{ type: "text", text: `no tool named "${name}"` }], isError: true };
|
|
443
|
+
return answers;
|
|
440
444
|
}
|
|
441
|
-
}
|
|
442
445
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
if (
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
446
|
+
function finish(callId: string, result: ClientResult): void {
|
|
447
|
+
const event = local("tool.completed", {
|
|
448
|
+
callId,
|
|
449
|
+
content: result.content,
|
|
450
|
+
isError: result.isError,
|
|
451
|
+
});
|
|
452
|
+
events.push(event);
|
|
453
|
+
void options.onEvent?.(task?.id ?? id, event);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Once a work is closed, only its summary stays in the conversation: the tool results are folded away. */
|
|
457
|
+
function foldAway(closed: TaskState, closingCallId: string): void {
|
|
458
|
+
for (const event of events) {
|
|
459
|
+
if (event.type !== "tool.completed") continue;
|
|
460
|
+
const payload = (event as Event<"tool.completed">).payload;
|
|
461
|
+
if (!closed.callIds.has(payload.callId) || payload.callId === closingCallId) continue;
|
|
462
|
+
payload.content = [
|
|
463
|
+
{
|
|
464
|
+
type: "text",
|
|
465
|
+
text: `(この結果は Work ${closed.id} を閉じたので省略。要点は work_complete の summary にある)`,
|
|
466
|
+
},
|
|
467
|
+
];
|
|
460
468
|
}
|
|
461
469
|
}
|
|
462
|
-
|
|
463
|
-
work.status === "waiting_input" ? pendingQuestions(events).map((q) => q.question) : [];
|
|
470
|
+
|
|
464
471
|
return {
|
|
465
|
-
id
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
472
|
+
id,
|
|
473
|
+
agentName,
|
|
474
|
+
async turn(text, turnOptions = {}) {
|
|
475
|
+
events.push(local("human.message", { text }));
|
|
476
|
+
await record(id, "human.message", { text });
|
|
477
|
+
if (candidate) {
|
|
478
|
+
const note = `候補の Work: ${candidate.id}(status: ${candidate.status}、objective: ${candidate.objective})。この依頼がその objective に沿うなら work_select で続ける。沿わなければ続けず、その旨を伝えて新しい Work を作るか work_list で探し直す。`;
|
|
479
|
+
events.push(
|
|
480
|
+
local("prompt.expanded", { name: "work resume", source: "builtin", text: note }),
|
|
481
|
+
);
|
|
482
|
+
await record(id, "prompt.expanded", { name: "work resume", source: "builtin", text: note });
|
|
483
|
+
}
|
|
484
|
+
try {
|
|
485
|
+
const result = await runTurn(turnOptions.signal);
|
|
486
|
+
return task ? { ...result, work: task.id } : result;
|
|
487
|
+
} finally {
|
|
488
|
+
// Whatever the turn did, the next one starts from the conversation: a work it left open
|
|
489
|
+
// stays as it is and comes back as a candidate through select; a declined candidate is dropped.
|
|
490
|
+
candidate = undefined;
|
|
491
|
+
if (task) {
|
|
492
|
+
task = undefined;
|
|
493
|
+
await client.call("work_select", { id }).catch(() => undefined);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
async select(workId) {
|
|
498
|
+
const got = await client.call("work_get", { id: workId });
|
|
499
|
+
if (got.isError) throw new Error(got.text);
|
|
500
|
+
const work = jsonOf(got) as Work;
|
|
501
|
+
if (isTerminal(work.status))
|
|
502
|
+
throw new Error(`${work.id} は ${work.status} で、続けられません`);
|
|
503
|
+
candidate = { id: work.id, objective: work.objective, status: work.status };
|
|
504
|
+
return work;
|
|
505
|
+
},
|
|
506
|
+
currentWork: () => task?.id,
|
|
507
|
+
async close() {
|
|
508
|
+
const selected = await client.call("work_select", { id });
|
|
509
|
+
if (selected.isError) {
|
|
510
|
+
const got = await client.call("work_get", { id });
|
|
511
|
+
return jsonOf(got) as Work;
|
|
512
|
+
}
|
|
513
|
+
const closed = await client.call("work_complete", { summary: "会話を終了" });
|
|
514
|
+
if (closed.isError) throw new Error(closed.text);
|
|
515
|
+
return jsonOf(closed) as Work;
|
|
516
|
+
},
|
|
480
517
|
};
|
|
481
518
|
}
|
|
482
519
|
|
|
520
|
+
/** The names of the sessions still open, so two people talking at once do not get the same one. */
|
|
521
|
+
async function namesInUse(client: RuntimeClient): Promise<string[]> {
|
|
522
|
+
const listed = await client.call("work_list", {});
|
|
523
|
+
if (listed.isError) return [];
|
|
524
|
+
const { works } = jsonOf(listed) as {
|
|
525
|
+
works: { type: string; status: string; agentName?: string }[];
|
|
526
|
+
};
|
|
527
|
+
return works
|
|
528
|
+
.filter((w) => w.type === SESSION_WORK_TYPE && !isTerminal(w.status as Work["status"]))
|
|
529
|
+
.flatMap((w) => (w.agentName ? [w.agentName] : []));
|
|
530
|
+
}
|
|
531
|
+
|
|
483
532
|
function textOf(content: AssistantPart[]): string {
|
|
484
533
|
return content
|
|
485
534
|
.filter((p) => p.type === "text")
|
|
@@ -487,22 +536,3 @@ function textOf(content: AssistantPart[]): string {
|
|
|
487
536
|
.join("\n")
|
|
488
537
|
.trim();
|
|
489
538
|
}
|
|
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
|
-
}
|