@openshain/agent 0.1.1 → 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 +5 -0
- package/dist/index.js +6 -0
- package/dist/names.d.ts +9 -0
- package/dist/names.js +79 -0
- package/dist/providers/anthropic.d.ts +40 -0
- package/dist/providers/anthropic.js +214 -0
- package/dist/providers/openai-compatible.d.ts +47 -0
- package/dist/providers/openai-compatible.js +251 -0
- package/dist/session.d.ts +50 -0
- package/dist/session.js +428 -0
- package/dist/testing/fake-model.d.ts +29 -0
- package/dist/testing/fake-model.js +39 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/package.json +24 -7
- package/src/client.ts +73 -0
- package/src/index.ts +2 -12
- package/src/session.ts +429 -399
- package/src/loop.ts +0 -479
package/dist/session.js
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import { ASK_USER_TOOL_NAME, buildProjection, eventToFile, isTerminal, newEventId, SESSION_WORK_TYPE, } from "@openshain/core";
|
|
2
|
+
import { jsonOf } from "./client.js";
|
|
3
|
+
import { pickAgentName } from "./names.js";
|
|
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(["work_record", "work_answer"]);
|
|
8
|
+
const ROLE = "あなたはこの会社の社員エージェントとして、受付の役で、この人と話す。作業が要るときは 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 で答える。";
|
|
9
|
+
/**
|
|
10
|
+
* Opens a conversation, recorded as a work of type "session", between the person and the model.
|
|
11
|
+
* The loop is a client of the runtime: it creates works, calls tools and closes works through
|
|
12
|
+
* the same MCP tools any other agent uses, and records its own model calls with work_record.
|
|
13
|
+
*/
|
|
14
|
+
export async function createSession(client, options) {
|
|
15
|
+
const { model, config } = options;
|
|
16
|
+
const agentName = options.agentName ?? pickAgentName(config.company.language, await namesInUse(client));
|
|
17
|
+
const opened = await client.call("work_create", {
|
|
18
|
+
objective: "会話",
|
|
19
|
+
type: SESSION_WORK_TYPE,
|
|
20
|
+
agent_name: agentName,
|
|
21
|
+
});
|
|
22
|
+
if (opened.isError)
|
|
23
|
+
throw new Error(`could not open a session: ${opened.text}`);
|
|
24
|
+
const session = jsonOf(opened);
|
|
25
|
+
const id = session.id;
|
|
26
|
+
/** The session's events as the projection needs them, kept in memory; the runtime holds the record. */
|
|
27
|
+
const events = [];
|
|
28
|
+
let seq = 0;
|
|
29
|
+
const local = (type, payload) => {
|
|
30
|
+
const now = new Date().toISOString();
|
|
31
|
+
seq += 1;
|
|
32
|
+
return {
|
|
33
|
+
v: 1,
|
|
34
|
+
id: newEventId(),
|
|
35
|
+
workId: id,
|
|
36
|
+
seq,
|
|
37
|
+
type,
|
|
38
|
+
payload,
|
|
39
|
+
occurredAt: now,
|
|
40
|
+
recordedAt: now,
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
events.push(local("work.created", {
|
|
44
|
+
objective: "会話",
|
|
45
|
+
principal: config.principal.id,
|
|
46
|
+
profession: config.profession.id,
|
|
47
|
+
type: SESSION_WORK_TYPE,
|
|
48
|
+
agentName,
|
|
49
|
+
}));
|
|
50
|
+
let task;
|
|
51
|
+
let candidate;
|
|
52
|
+
/** Records one of the client's own events on a work through the runtime, and reports it. */
|
|
53
|
+
const record = async (workId, type, payload) => {
|
|
54
|
+
const event = local(type, payload);
|
|
55
|
+
const file = eventToFile({ ...event, workId });
|
|
56
|
+
const result = await client.call("work_record", {
|
|
57
|
+
work_id: workId,
|
|
58
|
+
type,
|
|
59
|
+
payload: file.payload,
|
|
60
|
+
});
|
|
61
|
+
if (result.isError)
|
|
62
|
+
throw new Error(`work_record failed: ${result.text}`);
|
|
63
|
+
await options.onEvent?.(workId, { ...event, workId });
|
|
64
|
+
};
|
|
65
|
+
/** Records a model event on the session and, while a work is open, on that work as well. */
|
|
66
|
+
const recordModelEvent = async (type, payload) => {
|
|
67
|
+
events.push(local(type, payload));
|
|
68
|
+
await record(id, type, payload);
|
|
69
|
+
if (task)
|
|
70
|
+
await record(task.id, type, payload);
|
|
71
|
+
};
|
|
72
|
+
const describedTools = async () => (await client.listTools()).filter((t) => !LOOP_ONLY_TOOLS.has(t.name));
|
|
73
|
+
const promptConfig = {
|
|
74
|
+
...config,
|
|
75
|
+
profession: {
|
|
76
|
+
...config.profession,
|
|
77
|
+
instructions: `${config.profession.instructions.trim()}\n\n${ROLE}`,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
async function runTurn(signal) {
|
|
81
|
+
const description = model.describe();
|
|
82
|
+
const tools = await describedTools();
|
|
83
|
+
let modelCalls = 0;
|
|
84
|
+
let toolCalls = 0;
|
|
85
|
+
for (;;) {
|
|
86
|
+
if (signal?.aborted)
|
|
87
|
+
return { reply: "", stopped: "aborted" };
|
|
88
|
+
if (modelCalls >= TURN_LIMITS.modelCalls) {
|
|
89
|
+
return {
|
|
90
|
+
reply: "",
|
|
91
|
+
stopped: "turn_limit",
|
|
92
|
+
detail: `model calls in one turn (${TURN_LIMITS.modelCalls})`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const projection = buildProjection({
|
|
96
|
+
events,
|
|
97
|
+
config: promptConfig,
|
|
98
|
+
tools,
|
|
99
|
+
providerId: model.id,
|
|
100
|
+
budget: {
|
|
101
|
+
modelCallsLeft: TURN_LIMITS.modelCalls - modelCalls,
|
|
102
|
+
toolCallsLeft: TURN_LIMITS.toolCalls - toolCalls,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
if (task && task.modelCalls >= config.limits.maxModelCalls) {
|
|
106
|
+
await callTool({
|
|
107
|
+
id: `call_limit_${task.id}`,
|
|
108
|
+
name: "work_fail",
|
|
109
|
+
input: {
|
|
110
|
+
reason: "limit_reached",
|
|
111
|
+
detail: `${config.limits.maxModelCalls} model calls`,
|
|
112
|
+
},
|
|
113
|
+
}, signal);
|
|
114
|
+
return {
|
|
115
|
+
reply: "",
|
|
116
|
+
stopped: "turn_limit",
|
|
117
|
+
detail: `model calls in one work (${config.limits.maxModelCalls})`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
await recordModelEvent("model.requested", {
|
|
121
|
+
provider: model.id,
|
|
122
|
+
model: description.model,
|
|
123
|
+
messageCount: projection.messages.length,
|
|
124
|
+
toolNames: tools.map((t) => t.name),
|
|
125
|
+
});
|
|
126
|
+
modelCalls += 1;
|
|
127
|
+
if (task)
|
|
128
|
+
task.modelCalls += 1;
|
|
129
|
+
let response;
|
|
130
|
+
try {
|
|
131
|
+
response = await model.generate({
|
|
132
|
+
system: projection.system,
|
|
133
|
+
messages: projection.messages,
|
|
134
|
+
tools: projection.tools,
|
|
135
|
+
maxOutputTokens: config.limits.maxOutputTokens,
|
|
136
|
+
budget: projection.budget,
|
|
137
|
+
stableMessages: projection.messages.length - 1,
|
|
138
|
+
...(config.model?.options && { providerOptions: config.model.options }),
|
|
139
|
+
}, signal);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
143
|
+
await recordModelEvent("model.failed", { code: "model_error", message });
|
|
144
|
+
return { reply: "", stopped: signal?.aborted ? "aborted" : "model_error", detail: message };
|
|
145
|
+
}
|
|
146
|
+
await recordModelEvent("model.completed", {
|
|
147
|
+
stopReason: response.stopReason,
|
|
148
|
+
content: response.message.content,
|
|
149
|
+
...(config.debug?.persistRaw && response.raw !== undefined && { raw: response.raw }),
|
|
150
|
+
});
|
|
151
|
+
await recordModelEvent("usage.recorded", {
|
|
152
|
+
kind: "model_inference",
|
|
153
|
+
provider: model.id,
|
|
154
|
+
model: description.model,
|
|
155
|
+
usage: response.usage,
|
|
156
|
+
});
|
|
157
|
+
const text = textOf(response.message.content);
|
|
158
|
+
switch (response.stopReason) {
|
|
159
|
+
case "end_turn":
|
|
160
|
+
return { reply: text };
|
|
161
|
+
case "tool_call": {
|
|
162
|
+
const calls = response.message.content.filter((p) => p.type === "tool_call");
|
|
163
|
+
for (const call of calls) {
|
|
164
|
+
if (signal?.aborted)
|
|
165
|
+
return { reply: text, stopped: "aborted" };
|
|
166
|
+
if (toolCalls >= TURN_LIMITS.toolCalls) {
|
|
167
|
+
return {
|
|
168
|
+
reply: text,
|
|
169
|
+
stopped: "turn_limit",
|
|
170
|
+
detail: `tool calls in one turn (${TURN_LIMITS.toolCalls})`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
toolCalls += 1;
|
|
174
|
+
const outcome = await callTool(call, signal);
|
|
175
|
+
if (outcome === "withdrawn")
|
|
176
|
+
return { reply: text, stopped: "aborted" };
|
|
177
|
+
}
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
case "max_tokens":
|
|
181
|
+
return { reply: text, stopped: "max_tokens" };
|
|
182
|
+
case "refusal":
|
|
183
|
+
return { reply: text, stopped: "refusal" };
|
|
184
|
+
default:
|
|
185
|
+
return {
|
|
186
|
+
reply: text,
|
|
187
|
+
stopped: "model_error",
|
|
188
|
+
detail: `unexpected stop reason "${response.stopReason}"`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* One tool call of the model, through the runtime. Keeps track of the work the model is on,
|
|
195
|
+
* asks the person when the runtime says a question is pending, and mirrors the call and its
|
|
196
|
+
* result into the session's projection.
|
|
197
|
+
*/
|
|
198
|
+
async function callTool(call, signal) {
|
|
199
|
+
const workId = task?.id ?? id;
|
|
200
|
+
// The loop drives these itself; a model that calls them is refused before the runtime sees it.
|
|
201
|
+
const refusal = LOOP_ONLY_TOOLS.has(call.name)
|
|
202
|
+
? `${call.name} is the loop's own; it is not a tool for the model`
|
|
203
|
+
: !task && (call.name === "work_complete" || call.name === "work_fail")
|
|
204
|
+
? `${call.name} needs a work of its own: no work is open; start one with work_create`
|
|
205
|
+
: undefined;
|
|
206
|
+
if (refusal) {
|
|
207
|
+
finish(call.id, { content: [{ type: "text", text: refusal }], isError: true, text: "" });
|
|
208
|
+
return "done";
|
|
209
|
+
}
|
|
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
|
+
const input = call.name === "work_create" && call.input && typeof call.input === "object"
|
|
218
|
+
? { ...call.input, parent: id, agent_name: agentName }
|
|
219
|
+
: call.input;
|
|
220
|
+
let result;
|
|
221
|
+
try {
|
|
222
|
+
result = await client.call(call.name, input, signal);
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
result = {
|
|
226
|
+
content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
|
|
227
|
+
isError: true,
|
|
228
|
+
text: "",
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
task?.callIds.add(call.id);
|
|
232
|
+
const data = result.isError
|
|
233
|
+
? undefined
|
|
234
|
+
: jsonOf(result);
|
|
235
|
+
if (!result.isError &&
|
|
236
|
+
(call.name === "work_create" || call.name === "work_select") &&
|
|
237
|
+
data?.id) {
|
|
238
|
+
const workId = data.id;
|
|
239
|
+
const history = data.history;
|
|
240
|
+
task = {
|
|
241
|
+
id: workId,
|
|
242
|
+
modelCalls: typeof history?.modelCalls === "number" ? history.modelCalls : 0,
|
|
243
|
+
callIds: new Set([call.id]),
|
|
244
|
+
};
|
|
245
|
+
candidate = undefined;
|
|
246
|
+
await options.onEvent?.(workId, local("work.status_changed", {
|
|
247
|
+
from: "queued",
|
|
248
|
+
to: String(data.status ?? "in_progress"),
|
|
249
|
+
reason: call.name,
|
|
250
|
+
}));
|
|
251
|
+
// A selected work that waits for an answer gets it now, oldest question first.
|
|
252
|
+
if (call.name === "work_select" && data.status === "waiting_input") {
|
|
253
|
+
const answered = await answerPending(workId, signal);
|
|
254
|
+
if (answered === "withdrawn") {
|
|
255
|
+
finish(call.id, result);
|
|
256
|
+
return "withdrawn";
|
|
257
|
+
}
|
|
258
|
+
if (answered.length > 0) {
|
|
259
|
+
result = {
|
|
260
|
+
...result,
|
|
261
|
+
text: "",
|
|
262
|
+
content: [
|
|
263
|
+
...result.content,
|
|
264
|
+
{ type: "text", text: `answers recorded: ${JSON.stringify(answered)}` },
|
|
265
|
+
],
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (!result.isError && call.name === ASK_USER_TOOL_NAME && data?.pending === true && task) {
|
|
271
|
+
const asked = task.id;
|
|
272
|
+
const question = String(data.question ?? "");
|
|
273
|
+
if (!options.onInput) {
|
|
274
|
+
result = {
|
|
275
|
+
...result,
|
|
276
|
+
text: "",
|
|
277
|
+
content: [
|
|
278
|
+
{
|
|
279
|
+
type: "text",
|
|
280
|
+
text: "the work waits for the person's answer; it can be resumed later",
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
let answer;
|
|
287
|
+
try {
|
|
288
|
+
answer = await options.onInput(asked, question);
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// The person took the question back: the work stays waiting_input.
|
|
292
|
+
finish(call.id, {
|
|
293
|
+
content: [{ type: "text", text: "the person withdrew the question; the work waits" }],
|
|
294
|
+
isError: true,
|
|
295
|
+
text: "",
|
|
296
|
+
});
|
|
297
|
+
return "withdrawn";
|
|
298
|
+
}
|
|
299
|
+
const answered = await client.call("work_answer", { call_id: data.call_id, answer }, signal);
|
|
300
|
+
result = answered.isError
|
|
301
|
+
? answered
|
|
302
|
+
: { content: [{ type: "text", text: answer }], isError: false, text: answer };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
finish(call.id, result);
|
|
306
|
+
if (!result.isError && (call.name === "work_complete" || call.name === "work_fail") && task) {
|
|
307
|
+
const closed = task;
|
|
308
|
+
task = undefined;
|
|
309
|
+
foldAway(closed, call.id);
|
|
310
|
+
await options.onEvent?.(closed.id, local(call.name === "work_complete" ? "work.completed" : "work.failed", call.name === "work_complete"
|
|
311
|
+
? { summary: String(call.input?.summary ?? "") }
|
|
312
|
+
: { reason: String(call.input?.reason ?? ""), detail: "" }));
|
|
313
|
+
}
|
|
314
|
+
return "done";
|
|
315
|
+
}
|
|
316
|
+
/** Asks the person every question the work still waits on and records the answers. */
|
|
317
|
+
async function answerPending(workId, signal) {
|
|
318
|
+
if (!options.onInput)
|
|
319
|
+
return [];
|
|
320
|
+
const got = await client.call("work_get", { id: workId, history: true }, signal);
|
|
321
|
+
const history = jsonOf(got)?.history;
|
|
322
|
+
const answers = [];
|
|
323
|
+
for (const { callId, question } of history?.pending ?? []) {
|
|
324
|
+
let answer;
|
|
325
|
+
try {
|
|
326
|
+
answer = await options.onInput(workId, question);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return "withdrawn";
|
|
330
|
+
}
|
|
331
|
+
const recorded = await client.call("work_answer", { call_id: callId, answer }, signal);
|
|
332
|
+
if (recorded.isError)
|
|
333
|
+
throw new Error(recorded.text);
|
|
334
|
+
answers.push({ question, answer });
|
|
335
|
+
}
|
|
336
|
+
return answers;
|
|
337
|
+
}
|
|
338
|
+
function finish(callId, result) {
|
|
339
|
+
const event = local("tool.completed", {
|
|
340
|
+
callId,
|
|
341
|
+
content: result.content,
|
|
342
|
+
isError: result.isError,
|
|
343
|
+
});
|
|
344
|
+
events.push(event);
|
|
345
|
+
void options.onEvent?.(task?.id ?? id, event);
|
|
346
|
+
}
|
|
347
|
+
/** Once a work is closed, only its summary stays in the conversation: the tool results are folded away. */
|
|
348
|
+
function foldAway(closed, closingCallId) {
|
|
349
|
+
for (const event of events) {
|
|
350
|
+
if (event.type !== "tool.completed")
|
|
351
|
+
continue;
|
|
352
|
+
const payload = event.payload;
|
|
353
|
+
if (!closed.callIds.has(payload.callId) || payload.callId === closingCallId)
|
|
354
|
+
continue;
|
|
355
|
+
payload.content = [
|
|
356
|
+
{
|
|
357
|
+
type: "text",
|
|
358
|
+
text: `(この結果は Work ${closed.id} を閉じたので省略。要点は work_complete の summary にある)`,
|
|
359
|
+
},
|
|
360
|
+
];
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
id,
|
|
365
|
+
agentName,
|
|
366
|
+
async turn(text, turnOptions = {}) {
|
|
367
|
+
events.push(local("human.message", { text }));
|
|
368
|
+
await record(id, "human.message", { text });
|
|
369
|
+
if (candidate) {
|
|
370
|
+
const note = `候補の Work: ${candidate.id}(status: ${candidate.status}、objective: ${candidate.objective})。この依頼がその objective に沿うなら work_select で続ける。沿わなければ続けず、その旨を伝えて新しい Work を作るか work_list で探し直す。`;
|
|
371
|
+
events.push(local("prompt.expanded", { name: "work resume", source: "builtin", text: note }));
|
|
372
|
+
await record(id, "prompt.expanded", { name: "work resume", source: "builtin", text: note });
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
const result = await runTurn(turnOptions.signal);
|
|
376
|
+
return task ? { ...result, work: task.id } : result;
|
|
377
|
+
}
|
|
378
|
+
finally {
|
|
379
|
+
// Whatever the turn did, the next one starts from the conversation: a work it left open
|
|
380
|
+
// stays as it is and comes back as a candidate through select; a declined candidate is dropped.
|
|
381
|
+
candidate = undefined;
|
|
382
|
+
if (task) {
|
|
383
|
+
task = undefined;
|
|
384
|
+
await client.call("work_select", { id }).catch(() => undefined);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
async select(workId) {
|
|
389
|
+
const got = await client.call("work_get", { id: workId });
|
|
390
|
+
if (got.isError)
|
|
391
|
+
throw new Error(got.text);
|
|
392
|
+
const work = jsonOf(got);
|
|
393
|
+
if (isTerminal(work.status))
|
|
394
|
+
throw new Error(`${work.id} は ${work.status} で、続けられません`);
|
|
395
|
+
candidate = { id: work.id, objective: work.objective, status: work.status };
|
|
396
|
+
return work;
|
|
397
|
+
},
|
|
398
|
+
currentWork: () => task?.id,
|
|
399
|
+
async close() {
|
|
400
|
+
const selected = await client.call("work_select", { id });
|
|
401
|
+
if (selected.isError) {
|
|
402
|
+
const got = await client.call("work_get", { id });
|
|
403
|
+
return jsonOf(got);
|
|
404
|
+
}
|
|
405
|
+
const closed = await client.call("work_complete", { summary: "会話を終了" });
|
|
406
|
+
if (closed.isError)
|
|
407
|
+
throw new Error(closed.text);
|
|
408
|
+
return jsonOf(closed);
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
/** The names of the sessions still open, so two people talking at once do not get the same one. */
|
|
413
|
+
async function namesInUse(client) {
|
|
414
|
+
const listed = await client.call("work_list", {});
|
|
415
|
+
if (listed.isError)
|
|
416
|
+
return [];
|
|
417
|
+
const { works } = jsonOf(listed);
|
|
418
|
+
return works
|
|
419
|
+
.filter((w) => w.type === SESSION_WORK_TYPE && !isTerminal(w.status))
|
|
420
|
+
.flatMap((w) => (w.agentName ? [w.agentName] : []));
|
|
421
|
+
}
|
|
422
|
+
function textOf(content) {
|
|
423
|
+
return content
|
|
424
|
+
.filter((p) => p.type === "text")
|
|
425
|
+
.map((p) => p.text)
|
|
426
|
+
.join("\n")
|
|
427
|
+
.trim();
|
|
428
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ModelProvider, ModelRequest, ModelResponse } from "@openshain/core";
|
|
2
|
+
/** One scripted answer: a response, or a function that builds one from the request. */
|
|
3
|
+
export type FakeStep = ModelResponse | ((request: ModelRequest) => ModelResponse);
|
|
4
|
+
/**
|
|
5
|
+
* A model that answers from a script, one response per call, and remembers
|
|
6
|
+
* every request it saw. For tests and for trying tools without a real model.
|
|
7
|
+
*/
|
|
8
|
+
export declare class FakeModelProvider implements ModelProvider {
|
|
9
|
+
readonly id = "fake";
|
|
10
|
+
readonly requests: ModelRequest[];
|
|
11
|
+
private readonly steps;
|
|
12
|
+
constructor(steps: FakeStep[]);
|
|
13
|
+
describe(): {
|
|
14
|
+
provider: string;
|
|
15
|
+
model: string;
|
|
16
|
+
capabilities: {
|
|
17
|
+
tools: boolean;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
generate(request: ModelRequest): Promise<ModelResponse>;
|
|
21
|
+
}
|
|
22
|
+
/** A response that ends the turn with text. */
|
|
23
|
+
export declare function say(text: string): ModelResponse;
|
|
24
|
+
/** A response that asks for one or more tool calls. */
|
|
25
|
+
export declare function callTools(...calls: {
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
input: unknown;
|
|
29
|
+
}[]): ModelResponse;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A model that answers from a script, one response per call, and remembers
|
|
3
|
+
* every request it saw. For tests and for trying tools without a real model.
|
|
4
|
+
*/
|
|
5
|
+
export class FakeModelProvider {
|
|
6
|
+
id = "fake";
|
|
7
|
+
requests = [];
|
|
8
|
+
steps;
|
|
9
|
+
constructor(steps) {
|
|
10
|
+
this.steps = [...steps];
|
|
11
|
+
}
|
|
12
|
+
describe() {
|
|
13
|
+
return { provider: "fake", model: "fake-1", capabilities: { tools: true } };
|
|
14
|
+
}
|
|
15
|
+
async generate(request) {
|
|
16
|
+
this.requests.push(request);
|
|
17
|
+
const step = this.steps.shift();
|
|
18
|
+
if (!step)
|
|
19
|
+
throw new Error("the fake model ran out of scripted responses");
|
|
20
|
+
return typeof step === "function" ? step(request) : step;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** A response that ends the turn with text. */
|
|
24
|
+
export function say(text) {
|
|
25
|
+
return {
|
|
26
|
+
message: { role: "assistant", content: [{ type: "text", text }] },
|
|
27
|
+
stopReason: "end_turn",
|
|
28
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** A response that asks for one or more tool calls. */
|
|
32
|
+
export function callTools(...calls) {
|
|
33
|
+
const content = calls.map((c) => ({ type: "tool_call", ...c }));
|
|
34
|
+
return {
|
|
35
|
+
message: { role: "assistant", content },
|
|
36
|
+
stopReason: "tool_call",
|
|
37
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { callTools, FakeModelProvider, type FakeStep, say } from "./fake-model.ts";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { callTools, FakeModelProvider, say } from "./fake-model.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openshain/agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Tool loop and model providers (bring your own key)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openshain",
|
|
@@ -21,27 +21,44 @@
|
|
|
21
21
|
"bugs": "https://github.com/openshain/openshain/issues",
|
|
22
22
|
"type": "module",
|
|
23
23
|
"engines": {
|
|
24
|
+
"node": ">=22",
|
|
24
25
|
"bun": ">=1.3"
|
|
25
26
|
},
|
|
26
27
|
"files": [
|
|
28
|
+
"dist",
|
|
27
29
|
"src",
|
|
28
30
|
"!src/**/*.test.ts",
|
|
31
|
+
"!src/**/*.test.tsx",
|
|
29
32
|
"README.md",
|
|
30
33
|
"LICENSE"
|
|
31
34
|
],
|
|
32
35
|
"exports": {
|
|
33
|
-
".":
|
|
34
|
-
|
|
36
|
+
".": {
|
|
37
|
+
"bun": "./src/index.ts",
|
|
38
|
+
"types": "./dist/index.d.ts",
|
|
39
|
+
"import": "./dist/index.js"
|
|
40
|
+
},
|
|
41
|
+
"./testing": {
|
|
42
|
+
"bun": "./src/testing/index.ts",
|
|
43
|
+
"types": "./dist/testing/index.d.ts",
|
|
44
|
+
"import": "./dist/testing/index.js"
|
|
45
|
+
}
|
|
35
46
|
},
|
|
36
|
-
"
|
|
37
|
-
"
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "../../node_modules/.bin/tsc -p tsconfig.build.json",
|
|
49
|
+
"prepublishOnly": "rm -rf dist && ../../node_modules/.bin/tsc -p tsconfig.build.json"
|
|
38
50
|
},
|
|
39
51
|
"dependencies": {
|
|
40
52
|
"@anthropic-ai/sdk": "0.123.0",
|
|
41
|
-
"@
|
|
53
|
+
"@modelcontextprotocol/sdk": "1.30.0",
|
|
54
|
+
"@openshain/core": "0.3.1",
|
|
42
55
|
"openai": "7.10.0"
|
|
43
56
|
},
|
|
44
57
|
"devDependencies": {
|
|
45
|
-
"@openshain/
|
|
58
|
+
"@openshain/mcp": "0.3.1",
|
|
59
|
+
"@openshain/tools": "0.3.1"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
46
63
|
}
|
|
47
64
|
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
3
|
+
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
|
+
import type { JsonSchema, ToolContent, ToolDefinition } from "@openshain/core";
|
|
5
|
+
import pkg from "../package.json" with { type: "json" };
|
|
6
|
+
|
|
7
|
+
/** What a tool call returned, as the client sees it: MCP content, and the same as text. */
|
|
8
|
+
export interface ClientResult {
|
|
9
|
+
content: ToolContent[];
|
|
10
|
+
isError: boolean;
|
|
11
|
+
text: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The runtime as a client sees it: the tools it offers and a way to call them. The interactive
|
|
16
|
+
* CLI's loop talks to the runtime through this and nothing else, the way Claude Code does over MCP.
|
|
17
|
+
*/
|
|
18
|
+
export interface RuntimeClient {
|
|
19
|
+
listTools(): Promise<ToolDefinition[]>;
|
|
20
|
+
call(name: string, input: unknown, signal?: AbortSignal): Promise<ClientResult>;
|
|
21
|
+
close(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Connects an MCP client to a server in the same process, over the SDK's in-memory transport. */
|
|
25
|
+
export async function connectInMemory(server: Server): Promise<RuntimeClient> {
|
|
26
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
27
|
+
await server.connect(serverTransport);
|
|
28
|
+
const client = new Client({ name: "openshain", version: pkg.version });
|
|
29
|
+
await client.connect(clientTransport);
|
|
30
|
+
return wrap(client);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Adapts any connected MCP client to the runtime client the loop uses. */
|
|
34
|
+
export function wrap(client: Client): RuntimeClient {
|
|
35
|
+
return {
|
|
36
|
+
async listTools() {
|
|
37
|
+
const { tools } = await client.listTools();
|
|
38
|
+
return tools.map((tool) => ({
|
|
39
|
+
name: tool.name,
|
|
40
|
+
description: tool.description ?? "",
|
|
41
|
+
inputSchema: tool.inputSchema as JsonSchema,
|
|
42
|
+
effect: tool.annotations?.readOnlyHint === true ? "observe" : "mutate",
|
|
43
|
+
}));
|
|
44
|
+
},
|
|
45
|
+
async call(name, input, signal) {
|
|
46
|
+
const result = await client.callTool(
|
|
47
|
+
{ name, arguments: (input ?? {}) as Record<string, unknown> },
|
|
48
|
+
undefined,
|
|
49
|
+
signal ? { signal } : undefined,
|
|
50
|
+
);
|
|
51
|
+
const parts = (result.content ?? []) as { type: string; text?: string }[];
|
|
52
|
+
const content: ToolContent[] = parts.map((part) => ({
|
|
53
|
+
type: "text",
|
|
54
|
+
text: part.type === "text" ? (part.text ?? "") : JSON.stringify(part),
|
|
55
|
+
}));
|
|
56
|
+
return {
|
|
57
|
+
content,
|
|
58
|
+
isError: result.isError === true,
|
|
59
|
+
text: content.map((c) => (c.type === "text" ? c.text : "")).join(""),
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
close: () => client.close(),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Parses a JSON result. Returns undefined when the text is not JSON. */
|
|
67
|
+
export function jsonOf(result: ClientResult): unknown {
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(result.text);
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
// @openshain/agent:
|
|
1
|
+
// @openshain/agent: The conversation loop that drives the runtime as an MCP client, and the model providers (bring your own key)
|
|
2
2
|
|
|
3
|
-
export {
|
|
4
|
-
ASK_USER,
|
|
5
|
-
countToolCalls,
|
|
6
|
-
type FailureReason,
|
|
7
|
-
type PendingQuestion,
|
|
8
|
-
pendingQuestions,
|
|
9
|
-
RUNTIME_PROVIDER_ID,
|
|
10
|
-
type RunWorkOptions,
|
|
11
|
-
runWork,
|
|
12
|
-
} from "./loop.ts";
|
|
3
|
+
export { type ClientResult, connectInMemory, jsonOf, type RuntimeClient, wrap } from "./client.ts";
|
|
13
4
|
export { AGENT_NAMES, pickAgentName } from "./names.ts";
|
|
14
5
|
export {
|
|
15
6
|
ANTHROPIC_PROVIDER_ID,
|
|
@@ -25,7 +16,6 @@ export {
|
|
|
25
16
|
} from "./providers/openai-compatible.ts";
|
|
26
17
|
export {
|
|
27
18
|
createSession,
|
|
28
|
-
SESSION_TOOLS,
|
|
29
19
|
type Session,
|
|
30
20
|
type SessionOptions,
|
|
31
21
|
TURN_LIMITS,
|