@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.
Files changed (61) hide show
  1. package/LICENSE +179 -0
  2. package/README.md +31 -0
  3. package/dist/agent.cjs +761 -0
  4. package/dist/agent.d.cts +3 -0
  5. package/dist/agent.d.ts +3 -0
  6. package/dist/agent.js +23 -0
  7. package/dist/chunk-GXHL5CO7.js +353 -0
  8. package/dist/chunk-KPKMMGVC.js +671 -0
  9. package/dist/chunk-ODFEUVPW.js +100 -0
  10. package/dist/cli.cjs +858 -0
  11. package/dist/cli.d.cts +1 -0
  12. package/dist/cli.d.ts +1 -0
  13. package/dist/cli.js +454 -0
  14. package/dist/index-CEbS1SlS.d.cts +988 -0
  15. package/dist/index-DAxq7Y0R.d.ts +988 -0
  16. package/dist/index.cjs +1900 -0
  17. package/dist/index.d.cts +759 -0
  18. package/dist/index.d.ts +759 -0
  19. package/dist/index.js +771 -0
  20. package/dist/manifest.cjs +399 -0
  21. package/dist/manifest.d.cts +355 -0
  22. package/dist/manifest.d.ts +355 -0
  23. package/dist/manifest.js +38 -0
  24. package/package.json +56 -0
  25. package/src/agent/agent.ts +208 -0
  26. package/src/agent/index.ts +51 -0
  27. package/src/agent/loop.test.ts +261 -0
  28. package/src/agent/loop.ts +259 -0
  29. package/src/agent/model-call.ts +190 -0
  30. package/src/agent/query.ts +40 -0
  31. package/src/agent/tools.ts +295 -0
  32. package/src/builder.ts +473 -0
  33. package/src/cli/dev.test.ts +178 -0
  34. package/src/cli/dev.ts +425 -0
  35. package/src/cli.ts +390 -0
  36. package/src/contracts-lockstep.test.ts +77 -0
  37. package/src/generated/plugin-manifest.ts +1121 -0
  38. package/src/index.ts +141 -0
  39. package/src/manifest.test.ts +610 -0
  40. package/src/manifest.ts +589 -0
  41. package/src/mcp/bridge.test.ts +196 -0
  42. package/src/mcp/client.ts +253 -0
  43. package/src/mcp/fixture-server.ts +23 -0
  44. package/src/mcp/server.ts +351 -0
  45. package/src/model/client.test.ts +107 -0
  46. package/src/model/client.ts +179 -0
  47. package/src/model/gateway.ts +41 -0
  48. package/src/plugin/ryu-plugin.ts +191 -0
  49. package/src/runnable/agent.ts +338 -0
  50. package/src/runnable/app.ts +233 -0
  51. package/src/runnable/index.ts +61 -0
  52. package/src/runnable/primitives-hostapi.test.ts +73 -0
  53. package/src/runnable/primitives.test.ts +286 -0
  54. package/src/runnable/primitives.ts +610 -0
  55. package/src/runnable/runnable-types.ts +113 -0
  56. package/src/runnable/runnable.test.ts +397 -0
  57. package/src/runnable/skill.ts +60 -0
  58. package/src/runnable/tool.ts +260 -0
  59. package/src/runnable/turn-hook.test.ts +81 -0
  60. package/src/runnable/turn-hook.ts +191 -0
  61. package/src/runnable/workflow.ts +76 -0
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Native tool-calling model call for the Ryu agent runtime.
3
+ *
4
+ * The gateway-mandatory `ModelClient` (packages/sdk/src/model/client.ts) only
5
+ * exposes text `chat`/`stream` — it cannot pass a `tools` array or receive
6
+ * `tool_calls`. The autonomous agent loop needs both, so this module makes a
7
+ * direct request to the node's gateway `POST /v1/chat/completions` endpoint
8
+ * with the caller's own `tools` and reads back `message.tool_calls`.
9
+ *
10
+ * The no-direct-provider guarantee is preserved: `assertAllowedEgressUrl` (the
11
+ * same Rust-cored blocklist the ModelClient uses) is called before every fetch,
12
+ * so a direct provider base URL throws exactly as it would elsewhere in the SDK.
13
+ *
14
+ * `x-ryu-raw-tools: on` is always sent. On a plain gateway it is a harmless
15
+ * no-op; on a Composio-on managed node it forces the plain completion branch so
16
+ * the caller's `tool_calls` are returned verbatim instead of being intercepted
17
+ * and executed by Core's own tool loop (see apps/gateway/src/pipeline/mod.rs).
18
+ */
19
+
20
+ import { assertAllowedEgressUrl } from "../model/gateway.ts";
21
+
22
+ // ── OpenAI-compatible wire types (function-calling subset) ────────────────────
23
+
24
+ /** An OpenAI function tool definition passed to the model. */
25
+ export interface ToolFunctionDef {
26
+ function: {
27
+ description?: string;
28
+ name: string;
29
+ /** JSON Schema for the function's arguments. */
30
+ parameters: Record<string, unknown>;
31
+ };
32
+ type: "function";
33
+ }
34
+
35
+ /** A single tool call emitted by the model. */
36
+ export interface ToolCall {
37
+ function: {
38
+ /** JSON-encoded arguments string (per the OpenAI wire format). */
39
+ arguments: string;
40
+ name: string;
41
+ };
42
+ id: string;
43
+ type: "function";
44
+ }
45
+
46
+ /** An assistant turn — may carry text, tool calls, or both. */
47
+ export interface AssistantMessage {
48
+ content: string | null;
49
+ role: "assistant";
50
+ tool_calls?: ToolCall[];
51
+ }
52
+
53
+ /** A message in the loop's running transcript. */
54
+ export type LoopMessage =
55
+ | { content: string; role: "system" | "user" }
56
+ | AssistantMessage
57
+ | { content: string; role: "tool"; tool_call_id: string };
58
+
59
+ /** Token usage as reported by the gateway (optional — gateway may omit). */
60
+ export interface ModelUsage {
61
+ completionTokens: number;
62
+ promptTokens: number;
63
+ totalTokens: number;
64
+ }
65
+
66
+ /** Options for a single native tool-calling completion. */
67
+ export interface ModelCallOptions {
68
+ /** Gateway base URL (no trailing `/v1`). */
69
+ baseUrl: string;
70
+ /** Running transcript. */
71
+ messages: LoopMessage[];
72
+ /** Model id routed by the gateway (provider is derived from the id). */
73
+ model: string;
74
+ /** Abort signal for cancellation. */
75
+ signal?: AbortSignal;
76
+ /** Bearer token forwarded to the gateway (never a provider key). */
77
+ token?: string;
78
+ /** How the model should choose tools; defaults to gateway/provider default. */
79
+ toolChoice?: "auto" | "none" | "required";
80
+ /** Function tool definitions the model may call. */
81
+ tools?: ToolFunctionDef[];
82
+ }
83
+
84
+ /** Result of a single completion. */
85
+ export interface ModelCallResult {
86
+ finishReason: string | null;
87
+ message: AssistantMessage;
88
+ usage?: ModelUsage;
89
+ }
90
+
91
+ // ── Internal response shape (minimal subset we read) ──────────────────────────
92
+
93
+ interface ChatCompletionResponse {
94
+ choices?: Array<{
95
+ finish_reason?: string | null;
96
+ message?: {
97
+ content?: string | null;
98
+ tool_calls?: ToolCall[];
99
+ };
100
+ }>;
101
+ usage?: {
102
+ completion_tokens?: number;
103
+ prompt_tokens?: number;
104
+ total_tokens?: number;
105
+ };
106
+ }
107
+
108
+ const CHAT_COMPLETIONS_PATH = "/v1/chat/completions";
109
+
110
+ /** Strip a trailing slash so `baseUrl + path` never doubles up. */
111
+ function normalizeBaseUrl(baseUrl: string): string {
112
+ return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
113
+ }
114
+
115
+ /**
116
+ * Call the node's gateway with the caller's own tools and return the first
117
+ * choice, including any `tool_calls`.
118
+ *
119
+ * Throws when the base URL is a direct provider (egress enforcement) or when
120
+ * the gateway returns a non-2xx status.
121
+ */
122
+ export async function callModelWithTools(
123
+ options: ModelCallOptions
124
+ ): Promise<ModelCallResult> {
125
+ const base = normalizeBaseUrl(options.baseUrl);
126
+ // Preserve the BYOK-at-the-gateway rule — same blocklist as ModelClient.
127
+ assertAllowedEgressUrl(base);
128
+
129
+ const body: Record<string, unknown> = {
130
+ model: options.model,
131
+ messages: options.messages,
132
+ };
133
+ if (options.tools && options.tools.length > 0) {
134
+ body.tools = options.tools;
135
+ if (options.toolChoice) {
136
+ body.tool_choice = options.toolChoice;
137
+ }
138
+ }
139
+
140
+ const headers: Record<string, string> = {
141
+ "content-type": "application/json",
142
+ // Force the gateway's plain-completion branch so our own tool_calls are
143
+ // returned verbatim on Composio-on managed nodes.
144
+ "x-ryu-raw-tools": "on",
145
+ };
146
+ if (options.token) {
147
+ headers.authorization = `Bearer ${options.token}`;
148
+ }
149
+
150
+ const res = await fetch(`${base}${CHAT_COMPLETIONS_PATH}`, {
151
+ method: "POST",
152
+ headers,
153
+ body: JSON.stringify(body),
154
+ signal: options.signal,
155
+ });
156
+
157
+ if (!res.ok) {
158
+ const text = await res.text().catch(() => "");
159
+ throw new Error(
160
+ `[ryu-sdk] gateway ${res.status} ${res.statusText} at ${base}${CHAT_COMPLETIONS_PATH}${
161
+ text ? `: ${text}` : ""
162
+ }`
163
+ );
164
+ }
165
+
166
+ const json = (await res.json()) as ChatCompletionResponse;
167
+ const choice = json.choices?.[0];
168
+ const rawMessage = choice?.message;
169
+ const message: AssistantMessage = {
170
+ role: "assistant",
171
+ content: rawMessage?.content ?? null,
172
+ ...(rawMessage?.tool_calls && rawMessage.tool_calls.length > 0
173
+ ? { tool_calls: rawMessage.tool_calls }
174
+ : {}),
175
+ };
176
+
177
+ const usage: ModelUsage | undefined = json.usage
178
+ ? {
179
+ promptTokens: json.usage.prompt_tokens ?? 0,
180
+ completionTokens: json.usage.completion_tokens ?? 0,
181
+ totalTokens: json.usage.total_tokens ?? 0,
182
+ }
183
+ : undefined;
184
+
185
+ return {
186
+ message,
187
+ finishReason: choice?.finish_reason ?? null,
188
+ usage,
189
+ };
190
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `query()` — Claude-Agent-SDK-style streaming entry over the same `Agent`.
3
+ *
4
+ * Where `Agent` gives you Mastra's config-object + method shape, `query()` gives
5
+ * you the `for await (const msg of query({ prompt, options }))` ergonomics of
6
+ * the Claude Agent SDK. Both drive the identical loop; this is a thin wrapper.
7
+ */
8
+
9
+ import { Agent, type AgentConfig } from "./agent.ts";
10
+ import type { AgentEvent } from "./loop.ts";
11
+
12
+ /** Options accepted by `query` — an `AgentConfig` with an optional `name`. */
13
+ export type QueryOptions = Omit<AgentConfig, "name"> & { name?: string };
14
+
15
+ /** Input to `query`: a prompt plus agent options. */
16
+ export interface QueryInput {
17
+ options: QueryOptions;
18
+ prompt: string;
19
+ }
20
+
21
+ /**
22
+ * Run an agent for a single prompt and stream its events.
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * for await (const msg of query({
27
+ * prompt: "Find my expenses from last month.",
28
+ * options: { model: "gpt-4o", agentId: "agent-expense", tools: { gmailSearch } },
29
+ * })) {
30
+ * if (msg.type === "result") console.log(msg.text);
31
+ * }
32
+ * ```
33
+ */
34
+ export function query(input: QueryInput): AsyncGenerator<AgentEvent> {
35
+ const agent = new Agent({
36
+ name: input.options.name ?? "agent",
37
+ ...input.options,
38
+ });
39
+ return agent.stream(input.prompt);
40
+ }
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Tool resolution + execution for the Ryu agent runtime.
3
+ *
4
+ * Two kinds of tool feed the same model `tools[]` array:
5
+ *
6
+ * - **Local** tools are `ToolRunnable`s from `defineTool` — they run in-process
7
+ * via their `run(input, ctx)` implementation.
8
+ * - **Remote** tools are references to existing Ryu tools (e.g.
9
+ * `composio__GMAIL_SEARCH_EMAILS`) created with `ryuTool(id)`. Their schema is
10
+ * lazily fetched from Core `GET /api/tools/describe` and they execute through
11
+ * Core `POST /api/mcp/tools/call`, which enforces the agent's allowlist and
12
+ * selects the Composio connected-account entity via `user_id`.
13
+ *
14
+ * The model-facing function name is the **config key** the developer chose (e.g.
15
+ * `gmailSearch`), not the raw Composio slug — internals stay hidden and names
16
+ * stay OpenAI-safe.
17
+ */
18
+
19
+ import type { RunnableContext } from "../runnable/runnable-types.ts";
20
+ import type { ToolRunnable } from "../runnable/tool.ts";
21
+ import type { ToolFunctionDef } from "./model-call.ts";
22
+
23
+ // ── Remote tool reference ─────────────────────────────────────────────────────
24
+
25
+ /** A reference to an existing Ryu tool, resolved + executed via Core. */
26
+ export interface RemoteToolRef {
27
+ /** One-line description shown to the model (overrides Core's describe). */
28
+ description?: string;
29
+ /** Fully-qualified Ryu tool id, e.g. `composio__GMAIL_SEARCH_EMAILS`. */
30
+ id: string;
31
+ readonly kind: "remote";
32
+ /**
33
+ * JSON Schema for the tool's arguments. Optional: when omitted, a permissive
34
+ * open-object schema is used (Composio `describe` is shallow), so supplying
35
+ * this materially improves the model's tool-call accuracy.
36
+ */
37
+ parameters?: Record<string, unknown>;
38
+ }
39
+
40
+ /** Options accepted by `ryuTool`. */
41
+ export interface RyuToolOptions {
42
+ description?: string;
43
+ parameters?: Record<string, unknown>;
44
+ }
45
+
46
+ /**
47
+ * Reference an existing Ryu tool by id so an `Agent` can call it.
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * ryuTool("composio__GMAIL_SEARCH_EMAILS", {
52
+ * description: "Search the user's Gmail",
53
+ * parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
54
+ * });
55
+ * ```
56
+ */
57
+ export function ryuTool(id: string, opts: RyuToolOptions = {}): RemoteToolRef {
58
+ return {
59
+ kind: "remote",
60
+ id,
61
+ description: opts.description,
62
+ parameters: opts.parameters,
63
+ };
64
+ }
65
+
66
+ /** A tool an `Agent` can expose to the model: local runnable or remote ref. */
67
+ export type AgentTool = RemoteToolRef | ToolRunnable;
68
+
69
+ /** Narrow to a local `defineTool` runnable. */
70
+ function isLocalTool(tool: AgentTool): tool is ToolRunnable {
71
+ return tool.kind === "tool";
72
+ }
73
+
74
+ // ── Execution context ─────────────────────────────────────────────────────────
75
+
76
+ /** Everything the tool layer needs to resolve schemas + execute calls. */
77
+ export interface ToolExecContext {
78
+ /** Core agent id — REQUIRED for remote tools (governs execution). */
79
+ agentId?: string;
80
+ /** Core base URL (no trailing `/api`). */
81
+ coreBaseUrl: string;
82
+ /** Bearer token for Core (`RYU_TOKEN`); may be undefined on loopback dev. */
83
+ coreToken?: string;
84
+ /** RunnableContext handed to local tools so they may call the gateway. */
85
+ runnableContext: RunnableContext;
86
+ /** Abort signal. */
87
+ signal?: AbortSignal;
88
+ /** Composio connected-account entity selector. */
89
+ userId?: string;
90
+ }
91
+
92
+ const PERMISSIVE_OBJECT_SCHEMA: Record<string, unknown> = {
93
+ type: "object",
94
+ additionalProperties: true,
95
+ };
96
+
97
+ /** Core `/api/tools/describe` response subset we read. */
98
+ interface DescribeResponse {
99
+ description?: string;
100
+ name?: string;
101
+ }
102
+
103
+ function normalize(baseUrl: string): string {
104
+ return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
105
+ }
106
+
107
+ function authHeaders(token?: string): Record<string, string> {
108
+ const headers: Record<string, string> = {
109
+ "content-type": "application/json",
110
+ };
111
+ if (token) {
112
+ headers.authorization = `Bearer ${token}`;
113
+ }
114
+ return headers;
115
+ }
116
+
117
+ /**
118
+ * Build the OpenAI `tools[]` array the model sees, keyed by the developer's
119
+ * config names. Remote tools without an explicit `parameters` schema are
120
+ * described from Core (`describe` is shallow) and given a permissive schema.
121
+ */
122
+ export async function resolveToolDefs(
123
+ tools: Record<string, AgentTool>,
124
+ ctx: ToolExecContext
125
+ ): Promise<ToolFunctionDef[]> {
126
+ const defs: ToolFunctionDef[] = [];
127
+
128
+ for (const [name, tool] of Object.entries(tools)) {
129
+ if (isLocalTool(tool)) {
130
+ defs.push({
131
+ type: "function",
132
+ function: {
133
+ name,
134
+ description: tool.name,
135
+ parameters: tool.schema as unknown as Record<string, unknown>,
136
+ },
137
+ });
138
+ continue;
139
+ }
140
+
141
+ // Remote: prefer an explicit schema; else describe from Core.
142
+ let description = tool.description;
143
+ if (!description) {
144
+ description = await describeRemoteTool(tool.id, ctx);
145
+ }
146
+ defs.push({
147
+ type: "function",
148
+ function: {
149
+ name,
150
+ description: description ?? tool.id,
151
+ parameters: tool.parameters ?? PERMISSIVE_OBJECT_SCHEMA,
152
+ },
153
+ });
154
+ }
155
+
156
+ return defs;
157
+ }
158
+
159
+ async function describeRemoteTool(
160
+ id: string,
161
+ ctx: ToolExecContext
162
+ ): Promise<string | undefined> {
163
+ const url = `${normalize(ctx.coreBaseUrl)}/api/tools/describe?id=${encodeURIComponent(id)}`;
164
+ try {
165
+ const res = await fetch(url, {
166
+ headers: authHeaders(ctx.coreToken),
167
+ signal: ctx.signal,
168
+ });
169
+ if (!res.ok) {
170
+ return undefined;
171
+ }
172
+ const json = (await res.json()) as DescribeResponse;
173
+ return json.description || json.name || undefined;
174
+ } catch {
175
+ // Describe is best-effort — a missing description never blocks the loop.
176
+ return undefined;
177
+ }
178
+ }
179
+
180
+ // ── Execution ─────────────────────────────────────────────────────────────────
181
+
182
+ /** Result of executing one tool call. */
183
+ export interface ToolExecResult {
184
+ /** Raw tool output (already JSON-parsed when the tool returned JSON). */
185
+ output: unknown;
186
+ }
187
+
188
+ /**
189
+ * Execute a single model tool call by config `name`, dispatching to the local
190
+ * runnable or the Core `/api/mcp/tools/call` endpoint.
191
+ *
192
+ * `argsJson` is the raw JSON string from `tool_call.function.arguments`.
193
+ */
194
+ export async function executeTool(
195
+ name: string,
196
+ argsJson: string,
197
+ tools: Record<string, AgentTool>,
198
+ ctx: ToolExecContext
199
+ ): Promise<ToolExecResult> {
200
+ const tool = tools[name];
201
+ if (!tool) {
202
+ throw new Error(`[ryu-sdk] model called unknown tool "${name}"`);
203
+ }
204
+
205
+ const args = parseArgs(argsJson, name);
206
+
207
+ if (isLocalTool(tool)) {
208
+ const output = await tool.run(
209
+ args as Record<string, unknown>,
210
+ ctx.runnableContext
211
+ );
212
+ return { output };
213
+ }
214
+
215
+ // Remote tool — requires a Core agent id for governance.
216
+ if (!ctx.agentId) {
217
+ throw new Error(
218
+ `[ryu-sdk] remote tool "${name}" (${tool.id}) requires an agentId — ` +
219
+ "set it on the Agent config so Core can govern the call"
220
+ );
221
+ }
222
+
223
+ const url = `${normalize(ctx.coreBaseUrl)}/api/mcp/tools/call`;
224
+ const res = await fetch(url, {
225
+ method: "POST",
226
+ headers: authHeaders(ctx.coreToken),
227
+ signal: ctx.signal,
228
+ body: JSON.stringify({
229
+ tool: tool.id,
230
+ arguments: args,
231
+ agent_id: ctx.agentId,
232
+ ...(ctx.userId ? { user_id: ctx.userId } : {}),
233
+ }),
234
+ });
235
+
236
+ if (!res.ok) {
237
+ const text = await res.text().catch(() => "");
238
+ throw new Error(
239
+ `[ryu-sdk] Core tools/call ${res.status} for "${tool.id}"${text ? `: ${text}` : ""}`
240
+ );
241
+ }
242
+
243
+ const json = (await res.json()) as {
244
+ error?: string;
245
+ ok?: boolean;
246
+ output?: unknown;
247
+ };
248
+ if (json.ok === false) {
249
+ throw new Error(
250
+ `[ryu-sdk] tool "${tool.id}" failed: ${json.error ?? "unknown error"}`
251
+ );
252
+ }
253
+ return { output: json.output };
254
+ }
255
+
256
+ function parseArgs(argsJson: string, name: string): unknown {
257
+ const trimmed = (argsJson ?? "").trim();
258
+ if (trimmed === "") {
259
+ return {};
260
+ }
261
+ try {
262
+ return JSON.parse(trimmed);
263
+ } catch {
264
+ throw new Error(
265
+ `[ryu-sdk] tool "${name}" arguments were not valid JSON: ${argsJson}`
266
+ );
267
+ }
268
+ }
269
+
270
+ // ── Elicitation (connection-required) detection ───────────────────────────────
271
+
272
+ /** The connect-required envelope Core returns when an account isn't linked. */
273
+ export interface Elicitation {
274
+ kind?: string;
275
+ message?: string;
276
+ url?: string;
277
+ }
278
+
279
+ const ELICITATION_KEY = "__ryu_elicitation__";
280
+
281
+ /**
282
+ * Detect Ryu's connection-required envelope in a tool output. Mirrors Core's
283
+ * `detect_elicitation` (apps/core/src/sidecar/mcp/composio.rs): the first Gmail
284
+ * call for an unconnected account returns `{ "__ryu_elicitation__": { url } }`.
285
+ */
286
+ export function detectElicitation(output: unknown): Elicitation | null {
287
+ if (typeof output !== "object" || output === null) {
288
+ return null;
289
+ }
290
+ const envelope = (output as Record<string, unknown>)[ELICITATION_KEY];
291
+ if (typeof envelope !== "object" || envelope === null) {
292
+ return null;
293
+ }
294
+ return envelope as Elicitation;
295
+ }