@ryuhq/sdk 0.0.5
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/LICENSE +179 -0
- package/README.md +31 -0
- package/dist/agent.cjs +761 -0
- package/dist/agent.d.cts +3 -0
- package/dist/agent.d.ts +3 -0
- package/dist/agent.js +23 -0
- package/dist/chunk-GXHL5CO7.js +353 -0
- package/dist/chunk-KPKMMGVC.js +671 -0
- package/dist/chunk-ODFEUVPW.js +100 -0
- package/dist/cli.cjs +858 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +454 -0
- package/dist/index-CEbS1SlS.d.cts +988 -0
- package/dist/index-DAxq7Y0R.d.ts +988 -0
- package/dist/index.cjs +1900 -0
- package/dist/index.d.cts +759 -0
- package/dist/index.d.ts +759 -0
- package/dist/index.js +771 -0
- package/dist/manifest.cjs +399 -0
- package/dist/manifest.d.cts +355 -0
- package/dist/manifest.d.ts +355 -0
- package/dist/manifest.js +38 -0
- package/package.json +56 -0
- package/src/agent/agent.ts +208 -0
- package/src/agent/index.ts +51 -0
- package/src/agent/loop.test.ts +261 -0
- package/src/agent/loop.ts +259 -0
- package/src/agent/model-call.ts +190 -0
- package/src/agent/query.ts +40 -0
- package/src/agent/tools.ts +295 -0
- package/src/builder.ts +473 -0
- package/src/cli/dev.test.ts +178 -0
- package/src/cli/dev.ts +425 -0
- package/src/cli.ts +390 -0
- package/src/contracts-lockstep.test.ts +77 -0
- package/src/generated/plugin-manifest.ts +1121 -0
- package/src/index.ts +141 -0
- package/src/manifest.test.ts +610 -0
- package/src/manifest.ts +589 -0
- package/src/mcp/bridge.test.ts +196 -0
- package/src/mcp/client.ts +253 -0
- package/src/mcp/fixture-server.ts +23 -0
- package/src/mcp/server.ts +351 -0
- package/src/model/client.test.ts +107 -0
- package/src/model/client.ts +179 -0
- package/src/model/gateway.ts +41 -0
- package/src/plugin/ryu-plugin.ts +191 -0
- package/src/runnable/agent.ts +338 -0
- package/src/runnable/app.ts +233 -0
- package/src/runnable/index.ts +61 -0
- package/src/runnable/primitives-hostapi.test.ts +73 -0
- package/src/runnable/primitives.test.ts +286 -0
- package/src/runnable/primitives.ts +610 -0
- package/src/runnable/runnable-types.ts +113 -0
- package/src/runnable/runnable.test.ts +397 -0
- package/src/runnable/skill.ts +60 -0
- package/src/runnable/tool.ts +260 -0
- package/src/runnable/turn-hook.test.ts +81 -0
- package/src/runnable/turn-hook.ts +191 -0
- package/src/runnable/workflow.ts +76 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@ryu/sdk/agent` — the declarative agent runtime.
|
|
3
|
+
*
|
|
4
|
+
* Public entry for building apps on top of the SDK: a loop-owning `Agent`
|
|
5
|
+
* (Mastra-style) and a `query()` streaming call (Claude-Agent-SDK-style), plus
|
|
6
|
+
* `ryuTool` to reference existing Ryu tools and the event/type surface.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type {
|
|
10
|
+
AgentConfig,
|
|
11
|
+
Endpoint,
|
|
12
|
+
GenerateResult,
|
|
13
|
+
} from "./agent.ts";
|
|
14
|
+
export { Agent, createAgent } from "./agent.ts";
|
|
15
|
+
export type {
|
|
16
|
+
AgentEvent,
|
|
17
|
+
AgentEventAuthRequired,
|
|
18
|
+
AgentEventError,
|
|
19
|
+
AgentEventResult,
|
|
20
|
+
AgentEventText,
|
|
21
|
+
AgentEventToolCall,
|
|
22
|
+
AgentEventToolResult,
|
|
23
|
+
LoopConfig,
|
|
24
|
+
} from "./loop.ts";
|
|
25
|
+
export { runAgentLoop } from "./loop.ts";
|
|
26
|
+
export type {
|
|
27
|
+
AssistantMessage,
|
|
28
|
+
LoopMessage,
|
|
29
|
+
ModelCallOptions,
|
|
30
|
+
ModelCallResult,
|
|
31
|
+
ModelUsage,
|
|
32
|
+
ToolCall,
|
|
33
|
+
ToolFunctionDef,
|
|
34
|
+
} from "./model-call.ts";
|
|
35
|
+
export { callModelWithTools } from "./model-call.ts";
|
|
36
|
+
export type { QueryInput, QueryOptions } from "./query.ts";
|
|
37
|
+
export { query } from "./query.ts";
|
|
38
|
+
export type {
|
|
39
|
+
AgentTool,
|
|
40
|
+
Elicitation,
|
|
41
|
+
RemoteToolRef,
|
|
42
|
+
RyuToolOptions,
|
|
43
|
+
ToolExecContext,
|
|
44
|
+
ToolExecResult,
|
|
45
|
+
} from "./tools.ts";
|
|
46
|
+
export {
|
|
47
|
+
detectElicitation,
|
|
48
|
+
executeTool,
|
|
49
|
+
resolveToolDefs,
|
|
50
|
+
ryuTool,
|
|
51
|
+
} from "./tools.ts";
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the autonomous agent loop.
|
|
3
|
+
*
|
|
4
|
+
* The loop talks to the gateway and Core over `fetch` (the model client's native
|
|
5
|
+
* reqwest transport is bypassed here), so we stub `globalThis.fetch` and route
|
|
6
|
+
* by URL: gateway completions, Core tool calls, and Core describe. Egress
|
|
7
|
+
* enforcement is real (loopback passes), matching model/client.test.ts.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
11
|
+
import { defineTool } from "../runnable/tool.ts";
|
|
12
|
+
import { Agent } from "./agent.ts";
|
|
13
|
+
import type { AgentEvent } from "./loop.ts";
|
|
14
|
+
import type { ToolCall } from "./model-call.ts";
|
|
15
|
+
import { executeTool, ryuTool } from "./tools.ts";
|
|
16
|
+
|
|
17
|
+
const NODE = "http://127.0.0.1:7981";
|
|
18
|
+
const RE_AGENT_ID = /agentId/;
|
|
19
|
+
|
|
20
|
+
// ── Fetch stub ────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
let modelQueue: unknown[] = [];
|
|
23
|
+
let toolHandler: (body: Record<string, unknown>) => unknown = () => ({
|
|
24
|
+
ok: true,
|
|
25
|
+
output: {},
|
|
26
|
+
});
|
|
27
|
+
let originalFetch: typeof globalThis.fetch;
|
|
28
|
+
|
|
29
|
+
function modelResponse(opts: {
|
|
30
|
+
content?: string | null;
|
|
31
|
+
finish?: string;
|
|
32
|
+
toolCalls?: ToolCall[];
|
|
33
|
+
}) {
|
|
34
|
+
const toolCalls = opts.toolCalls ?? [];
|
|
35
|
+
return {
|
|
36
|
+
choices: [
|
|
37
|
+
{
|
|
38
|
+
finish_reason:
|
|
39
|
+
opts.finish ?? (toolCalls.length > 0 ? "tool_calls" : "stop"),
|
|
40
|
+
message: {
|
|
41
|
+
content: opts.content ?? null,
|
|
42
|
+
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
usage: { prompt_tokens: 3, completion_tokens: 5, total_tokens: 8 },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toolCall(id: string, name: string, args: unknown): ToolCall {
|
|
51
|
+
return {
|
|
52
|
+
id,
|
|
53
|
+
type: "function",
|
|
54
|
+
function: { name, arguments: JSON.stringify(args) },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
originalFetch = globalThis.fetch;
|
|
60
|
+
modelQueue = [];
|
|
61
|
+
toolHandler = () => ({ ok: true, output: {} });
|
|
62
|
+
globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => {
|
|
63
|
+
const url = String(input);
|
|
64
|
+
if (url.includes("/v1/chat/completions")) {
|
|
65
|
+
const next = modelQueue.shift() ?? modelResponse({ content: "done" });
|
|
66
|
+
return Promise.resolve(
|
|
67
|
+
new Response(JSON.stringify(next), { status: 200 })
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (url.includes("/api/mcp/tools/call")) {
|
|
71
|
+
const body = JSON.parse(String(init?.body ?? "{}")) as Record<
|
|
72
|
+
string,
|
|
73
|
+
unknown
|
|
74
|
+
>;
|
|
75
|
+
return Promise.resolve(
|
|
76
|
+
new Response(JSON.stringify(toolHandler(body)), { status: 200 })
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (url.includes("/api/tools/describe")) {
|
|
80
|
+
return Promise.resolve(
|
|
81
|
+
new Response(JSON.stringify({ description: "desc" }), { status: 200 })
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return Promise.resolve(new Response("not found", { status: 404 }));
|
|
85
|
+
}) as typeof globalThis.fetch;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
globalThis.fetch = originalFetch;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
async function collect(gen: AsyncGenerator<AgentEvent>): Promise<AgentEvent[]> {
|
|
93
|
+
const events: AgentEvent[] = [];
|
|
94
|
+
for await (const event of gen) {
|
|
95
|
+
events.push(event);
|
|
96
|
+
}
|
|
97
|
+
return events;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
describe("runAgentLoop", () => {
|
|
103
|
+
it("executes a local tool, feeds the result back, and terminates", async () => {
|
|
104
|
+
let ran = false;
|
|
105
|
+
const echo = defineTool({
|
|
106
|
+
id: "echo",
|
|
107
|
+
name: "Echo the input",
|
|
108
|
+
schema: {
|
|
109
|
+
type: "object",
|
|
110
|
+
properties: { text: { type: "string" } },
|
|
111
|
+
required: ["text"],
|
|
112
|
+
},
|
|
113
|
+
run: (input) => {
|
|
114
|
+
ran = true;
|
|
115
|
+
return Promise.resolve({ echoed: input.text });
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
modelQueue = [
|
|
120
|
+
modelResponse({ toolCalls: [toolCall("c1", "echo", { text: "hi" })] }),
|
|
121
|
+
modelResponse({ content: "All done.", finish: "stop" }),
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
const agent = new Agent({
|
|
125
|
+
name: "t",
|
|
126
|
+
model: "gpt-4o",
|
|
127
|
+
node: { baseUrl: NODE },
|
|
128
|
+
tools: { echo },
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const events = await collect(agent.stream("go"));
|
|
132
|
+
const types = events.map((e) => e.type);
|
|
133
|
+
|
|
134
|
+
expect(ran).toBe(true);
|
|
135
|
+
expect(types).toContain("tool_call");
|
|
136
|
+
expect(types).toContain("tool_result");
|
|
137
|
+
const result = events.find((e) => e.type === "result");
|
|
138
|
+
expect(result).toBeDefined();
|
|
139
|
+
expect(result?.type === "result" && result.text).toBe("All done.");
|
|
140
|
+
// Usage aggregates across both model rounds (8 + 8).
|
|
141
|
+
expect(result?.type === "result" && result.usage?.totalTokens).toBe(16);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("executes a remote tool via Core /api/mcp/tools/call", async () => {
|
|
145
|
+
const captured: {
|
|
146
|
+
body: { agent_id?: unknown; tool?: unknown; user_id?: unknown } | null;
|
|
147
|
+
} = { body: null };
|
|
148
|
+
toolHandler = (body) => {
|
|
149
|
+
captured.body = body;
|
|
150
|
+
return { ok: true, output: { messages: ["expense receipt"] } };
|
|
151
|
+
};
|
|
152
|
+
modelQueue = [
|
|
153
|
+
modelResponse({
|
|
154
|
+
toolCalls: [toolCall("c1", "gmailSearch", { query: "receipt" })],
|
|
155
|
+
}),
|
|
156
|
+
modelResponse({ content: "Found 1 expense.", finish: "stop" }),
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
const agent = new Agent({
|
|
160
|
+
name: "expense",
|
|
161
|
+
model: "gpt-4o",
|
|
162
|
+
node: { baseUrl: NODE },
|
|
163
|
+
agentId: "agent-expense",
|
|
164
|
+
userId: "user-1",
|
|
165
|
+
tools: {
|
|
166
|
+
gmailSearch: ryuTool("composio__GMAIL_SEARCH_EMAILS", {
|
|
167
|
+
description: "Search Gmail",
|
|
168
|
+
parameters: {
|
|
169
|
+
type: "object",
|
|
170
|
+
properties: { query: { type: "string" } },
|
|
171
|
+
required: ["query"],
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const events = await collect(agent.stream("find expenses"));
|
|
178
|
+
expect(captured.body).not.toBeNull();
|
|
179
|
+
expect(captured.body?.tool).toBe("composio__GMAIL_SEARCH_EMAILS");
|
|
180
|
+
expect(captured.body?.agent_id).toBe("agent-expense");
|
|
181
|
+
expect(captured.body?.user_id).toBe("user-1");
|
|
182
|
+
const result = events.find((e) => e.type === "result");
|
|
183
|
+
expect(result?.type === "result" && result.text).toBe("Found 1 expense.");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("pauses with auth_required when a remote tool returns an elicitation", async () => {
|
|
187
|
+
toolHandler = () => ({
|
|
188
|
+
ok: true,
|
|
189
|
+
output: {
|
|
190
|
+
__ryu_elicitation__: {
|
|
191
|
+
kind: "url",
|
|
192
|
+
url: "https://connect.example/gmail",
|
|
193
|
+
message: "Connect your Gmail",
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
modelQueue = [
|
|
198
|
+
modelResponse({
|
|
199
|
+
toolCalls: [toolCall("c1", "gmailSearch", { query: "x" })],
|
|
200
|
+
}),
|
|
201
|
+
// This second response must NOT be consumed — the loop stops on auth.
|
|
202
|
+
modelResponse({ content: "should not reach", finish: "stop" }),
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
const agent = new Agent({
|
|
206
|
+
name: "expense",
|
|
207
|
+
model: "gpt-4o",
|
|
208
|
+
node: { baseUrl: NODE },
|
|
209
|
+
agentId: "agent-expense",
|
|
210
|
+
tools: {
|
|
211
|
+
gmailSearch: ryuTool("composio__GMAIL_SEARCH_EMAILS", {
|
|
212
|
+
parameters: { type: "object", properties: {} },
|
|
213
|
+
}),
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const events = await collect(agent.stream("find expenses"));
|
|
218
|
+
const auth = events.find((e) => e.type === "auth_required");
|
|
219
|
+
expect(auth?.type === "auth_required" && auth.url).toBe(
|
|
220
|
+
"https://connect.example/gmail"
|
|
221
|
+
);
|
|
222
|
+
// Loop stopped: no result event, second model response left unconsumed.
|
|
223
|
+
expect(events.some((e) => e.type === "result")).toBe(false);
|
|
224
|
+
expect(modelQueue.length).toBe(1);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("query() yields the same terminal result as Agent.generate()", async () => {
|
|
228
|
+
const { query } = await import("./query.ts");
|
|
229
|
+
modelQueue = [modelResponse({ content: "Hello there.", finish: "stop" })];
|
|
230
|
+
|
|
231
|
+
const events = await collect(
|
|
232
|
+
query({
|
|
233
|
+
prompt: "hi",
|
|
234
|
+
options: { model: "gpt-4o", node: { baseUrl: NODE } },
|
|
235
|
+
})
|
|
236
|
+
);
|
|
237
|
+
const result = events.find((e) => e.type === "result");
|
|
238
|
+
expect(result?.type === "result" && result.text).toBe("Hello there.");
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe("executeTool", () => {
|
|
243
|
+
it("throws when a remote tool is used without an agentId", async () => {
|
|
244
|
+
const tools = {
|
|
245
|
+
gmailSearch: ryuTool("composio__GMAIL_SEARCH_EMAILS"),
|
|
246
|
+
};
|
|
247
|
+
await expect(
|
|
248
|
+
executeTool("gmailSearch", "{}", tools, {
|
|
249
|
+
coreBaseUrl: "http://127.0.0.1:7980",
|
|
250
|
+
runnableContext: {
|
|
251
|
+
gateway: {
|
|
252
|
+
chat: () => Promise.reject(new Error("unused")),
|
|
253
|
+
async *stream() {
|
|
254
|
+
// no-op
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
})
|
|
259
|
+
).rejects.toThrow(RE_AGENT_ID);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The autonomous agent loop for the Ryu SDK runtime.
|
|
3
|
+
*
|
|
4
|
+
* This is what `defineAgent` never had: a real multi-turn tool-calling loop that
|
|
5
|
+
* runs in TypeScript. Each round calls the node's gateway with the resolved
|
|
6
|
+
* tool definitions; if the model emits `tool_calls`, each is executed (local
|
|
7
|
+
* runnable or Core `/api/mcp/tools/call`), results are fed back, and the loop
|
|
8
|
+
* repeats until the model stops calling tools or `maxSteps` is reached.
|
|
9
|
+
*
|
|
10
|
+
* Emitted events mirror Core's `AcpEvent` categories (see cli/dev.ts) plus an
|
|
11
|
+
* `auth_required` pause — when a remote tool returns Ryu's connection-required
|
|
12
|
+
* envelope (first-run Gmail OAuth), the loop surfaces the connect URL and stops
|
|
13
|
+
* instead of feeding the envelope back as a normal tool result.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
type AssistantMessage,
|
|
18
|
+
callModelWithTools,
|
|
19
|
+
type LoopMessage,
|
|
20
|
+
type ModelUsage,
|
|
21
|
+
type ToolCall,
|
|
22
|
+
} from "./model-call.ts";
|
|
23
|
+
import {
|
|
24
|
+
type AgentTool,
|
|
25
|
+
detectElicitation,
|
|
26
|
+
executeTool,
|
|
27
|
+
resolveToolDefs,
|
|
28
|
+
type ToolExecContext,
|
|
29
|
+
} from "./tools.ts";
|
|
30
|
+
|
|
31
|
+
// ── Events ────────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/** A streamed text fragment from the assistant. */
|
|
34
|
+
export interface AgentEventText {
|
|
35
|
+
content: string;
|
|
36
|
+
type: "text";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The model initiated a tool call. */
|
|
40
|
+
export interface AgentEventToolCall {
|
|
41
|
+
id: string;
|
|
42
|
+
input: unknown;
|
|
43
|
+
name: string;
|
|
44
|
+
type: "tool_call";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A tool finished (or failed with an error output the model can recover from). */
|
|
48
|
+
export interface AgentEventToolResult {
|
|
49
|
+
id: string;
|
|
50
|
+
name: string;
|
|
51
|
+
output: unknown;
|
|
52
|
+
type: "tool_result";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A remote tool needs an account connection — the loop paused. */
|
|
56
|
+
export interface AgentEventAuthRequired {
|
|
57
|
+
message?: string;
|
|
58
|
+
tool: string;
|
|
59
|
+
type: "auth_required";
|
|
60
|
+
url?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A fatal loop error — the stream ends after this. */
|
|
64
|
+
export interface AgentEventError {
|
|
65
|
+
message: string;
|
|
66
|
+
type: "error";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Terminal event carrying the final text, step count, and aggregate usage. */
|
|
70
|
+
export interface AgentEventResult {
|
|
71
|
+
steps: number;
|
|
72
|
+
text: string;
|
|
73
|
+
type: "result";
|
|
74
|
+
usage?: ModelUsage;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Union of everything the loop yields. */
|
|
78
|
+
export type AgentEvent =
|
|
79
|
+
| AgentEventAuthRequired
|
|
80
|
+
| AgentEventError
|
|
81
|
+
| AgentEventResult
|
|
82
|
+
| AgentEventText
|
|
83
|
+
| AgentEventToolCall
|
|
84
|
+
| AgentEventToolResult;
|
|
85
|
+
|
|
86
|
+
// ── Config ────────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/** Inputs for a single loop run. */
|
|
89
|
+
export interface LoopConfig {
|
|
90
|
+
/** Gateway base URL for inference (the target node). */
|
|
91
|
+
gatewayBaseUrl: string;
|
|
92
|
+
/** Gateway bearer token. */
|
|
93
|
+
gatewayToken?: string;
|
|
94
|
+
/** Hard ceiling on model→tool rounds. */
|
|
95
|
+
maxSteps: number;
|
|
96
|
+
/** Seed transcript (system + user messages already assembled). */
|
|
97
|
+
messages: LoopMessage[];
|
|
98
|
+
/** Model id routed by the gateway. */
|
|
99
|
+
model: string;
|
|
100
|
+
/** Abort signal. */
|
|
101
|
+
signal?: AbortSignal;
|
|
102
|
+
/** Context for resolving + executing tools. */
|
|
103
|
+
toolCtx: ToolExecContext;
|
|
104
|
+
/** Tools keyed by model-facing name. */
|
|
105
|
+
tools: Record<string, AgentTool>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function safeParse(json: string): unknown {
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(json);
|
|
111
|
+
} catch {
|
|
112
|
+
return json;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function addUsage(a: ModelUsage | undefined, b: ModelUsage | undefined) {
|
|
117
|
+
if (!(a || b)) {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
promptTokens: (a?.promptTokens ?? 0) + (b?.promptTokens ?? 0),
|
|
122
|
+
completionTokens: (a?.completionTokens ?? 0) + (b?.completionTokens ?? 0),
|
|
123
|
+
totalTokens: (a?.totalTokens ?? 0) + (b?.totalTokens ?? 0),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Look at a tool output (object or JSON string) for the elicitation envelope. */
|
|
128
|
+
function findElicitation(output: unknown) {
|
|
129
|
+
const direct = detectElicitation(output);
|
|
130
|
+
if (direct) {
|
|
131
|
+
return direct;
|
|
132
|
+
}
|
|
133
|
+
if (typeof output === "string") {
|
|
134
|
+
return detectElicitation(safeParse(output));
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Run the autonomous loop, yielding events as they occur. The generator returns
|
|
141
|
+
* after a terminal `result`, `auth_required`, or `error` event.
|
|
142
|
+
*/
|
|
143
|
+
export async function* runAgentLoop(
|
|
144
|
+
config: LoopConfig
|
|
145
|
+
): AsyncGenerator<AgentEvent> {
|
|
146
|
+
const messages = [...config.messages];
|
|
147
|
+
let toolDefs: Awaited<ReturnType<typeof resolveToolDefs>>;
|
|
148
|
+
try {
|
|
149
|
+
toolDefs = await resolveToolDefs(config.tools, config.toolCtx);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
yield { type: "error", message: describeError(err) };
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let usage: ModelUsage | undefined;
|
|
156
|
+
let lastText = "";
|
|
157
|
+
|
|
158
|
+
for (let step = 1; step <= config.maxSteps; step++) {
|
|
159
|
+
let result: Awaited<ReturnType<typeof callModelWithTools>>;
|
|
160
|
+
try {
|
|
161
|
+
result = await callModelWithTools({
|
|
162
|
+
baseUrl: config.gatewayBaseUrl,
|
|
163
|
+
token: config.gatewayToken,
|
|
164
|
+
model: config.model,
|
|
165
|
+
messages,
|
|
166
|
+
tools: toolDefs.length > 0 ? toolDefs : undefined,
|
|
167
|
+
toolChoice: toolDefs.length > 0 ? "auto" : undefined,
|
|
168
|
+
signal: config.signal,
|
|
169
|
+
});
|
|
170
|
+
} catch (err) {
|
|
171
|
+
yield { type: "error", message: describeError(err) };
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
usage = addUsage(usage, result.usage);
|
|
176
|
+
const assistant: AssistantMessage = result.message;
|
|
177
|
+
messages.push(assistant);
|
|
178
|
+
|
|
179
|
+
if (assistant.content) {
|
|
180
|
+
lastText = assistant.content;
|
|
181
|
+
yield { type: "text", content: assistant.content };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const toolCalls = assistant.tool_calls ?? [];
|
|
185
|
+
if (toolCalls.length === 0) {
|
|
186
|
+
yield { type: "result", text: lastText, steps: step, usage };
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const paused = yield* runToolCalls(toolCalls, messages, config);
|
|
191
|
+
if (paused) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Ran out of steps — surface what we have rather than hang.
|
|
197
|
+
yield { type: "result", text: lastText, steps: config.maxSteps, usage };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Execute the model's tool calls, appending results to `messages`. Yields
|
|
202
|
+
* tool_call/tool_result/auth_required events. Returns `true` when the loop must
|
|
203
|
+
* stop (an elicitation pause was surfaced).
|
|
204
|
+
*/
|
|
205
|
+
async function* runToolCalls(
|
|
206
|
+
toolCalls: ToolCall[],
|
|
207
|
+
messages: LoopMessage[],
|
|
208
|
+
config: LoopConfig
|
|
209
|
+
): AsyncGenerator<AgentEvent, boolean> {
|
|
210
|
+
for (const call of toolCalls) {
|
|
211
|
+
const name = call.function.name;
|
|
212
|
+
const input = safeParse(call.function.arguments);
|
|
213
|
+
yield { type: "tool_call", id: call.id, name, input };
|
|
214
|
+
|
|
215
|
+
let output: unknown;
|
|
216
|
+
try {
|
|
217
|
+
const res = await executeTool(
|
|
218
|
+
name,
|
|
219
|
+
call.function.arguments,
|
|
220
|
+
config.tools,
|
|
221
|
+
config.toolCtx
|
|
222
|
+
);
|
|
223
|
+
output = res.output;
|
|
224
|
+
} catch (err) {
|
|
225
|
+
// Recoverable: feed the error back so the model can adjust.
|
|
226
|
+
const errPayload = { error: describeError(err) };
|
|
227
|
+
messages.push({
|
|
228
|
+
role: "tool",
|
|
229
|
+
tool_call_id: call.id,
|
|
230
|
+
content: JSON.stringify(errPayload),
|
|
231
|
+
});
|
|
232
|
+
yield { type: "tool_result", id: call.id, name, output: errPayload };
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const elicitation = findElicitation(output);
|
|
237
|
+
if (elicitation) {
|
|
238
|
+
yield {
|
|
239
|
+
type: "auth_required",
|
|
240
|
+
tool: name,
|
|
241
|
+
url: elicitation.url,
|
|
242
|
+
message: elicitation.message,
|
|
243
|
+
};
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
messages.push({
|
|
248
|
+
role: "tool",
|
|
249
|
+
tool_call_id: call.id,
|
|
250
|
+
content: typeof output === "string" ? output : JSON.stringify(output),
|
|
251
|
+
});
|
|
252
|
+
yield { type: "tool_result", id: call.id, name, output };
|
|
253
|
+
}
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function describeError(err: unknown): string {
|
|
258
|
+
return err instanceof Error ? err.message : String(err);
|
|
259
|
+
}
|