@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,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ryu App authoring factory — `defineApp`.
|
|
3
|
+
*
|
|
4
|
+
* A "Ryu App" bundles one or more tools whose results render an interactive
|
|
5
|
+
* widget inline in chat (the ChatGPT-Apps-style surface). `defineApp` assembles a
|
|
6
|
+
* complete `plugin.json` `PluginManifest` from a declarative description, deriving
|
|
7
|
+
* the render-vs-companion split exactly the way Core's in-process provider does
|
|
8
|
+
* (`apps/core/src/sidecar/mcp/apps/mod.rs` `tools()`):
|
|
9
|
+
*
|
|
10
|
+
* - A **render** tool (`accessible` unset/false) produces the widget: it gets a
|
|
11
|
+
* `contributes.widgets[]` entry binding its id to the app's
|
|
12
|
+
* `ui://widget/<slug>.html` template, and its runnable config carries
|
|
13
|
+
* `widget:true` plus `invoking`/`invoked` status labels.
|
|
14
|
+
* - A **companion** tool (`accessible:true`) is a call target a mounted widget
|
|
15
|
+
* may invoke: it carries `widget_accessible:true` and gets no widget template.
|
|
16
|
+
*
|
|
17
|
+
* v1 boundary: this is **declarative pass-through only** — there is no `run`
|
|
18
|
+
* handler. Third-party tool code execution needs the plugin runtime (out of
|
|
19
|
+
* scope); the widget renders from `window.openai.toolInput`/`toolOutput` and Core
|
|
20
|
+
* echoes the validated arguments as `structuredContent`. `ryu pack` bundles the
|
|
21
|
+
* `uiEntry` source into the manifest's `ui_code`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type {
|
|
25
|
+
Contributes,
|
|
26
|
+
PluginManifest,
|
|
27
|
+
Requires,
|
|
28
|
+
RunnableMeta,
|
|
29
|
+
Surface,
|
|
30
|
+
ToolAppConfig,
|
|
31
|
+
WidgetContribution,
|
|
32
|
+
} from "../manifest.ts";
|
|
33
|
+
import { PluginManifestSchema } from "../manifest.ts";
|
|
34
|
+
|
|
35
|
+
/** The default widget MIME dialect (mirrors Core `default_widget_mime`). */
|
|
36
|
+
const DEFAULT_APP_WIDGET_MIME = "text/html+skybridge";
|
|
37
|
+
/** The default widget display mode (mirrors Core `default_widget_display_mode`). */
|
|
38
|
+
const DEFAULT_APP_DISPLAY_MODE = "inline";
|
|
39
|
+
|
|
40
|
+
/** One tool a Ryu App declares. */
|
|
41
|
+
export interface AppToolSpec {
|
|
42
|
+
/**
|
|
43
|
+
* True when this is a **companion** tool — a call target a mounted widget may
|
|
44
|
+
* `callTool`. False/unset makes it a **render** tool that produces the widget.
|
|
45
|
+
*/
|
|
46
|
+
accessible?: boolean;
|
|
47
|
+
/** Human-readable description the model reads when choosing the tool. */
|
|
48
|
+
description: string;
|
|
49
|
+
/** JSON Schema object describing the tool's arguments. Optional. */
|
|
50
|
+
inputSchema?: Record<string, unknown>;
|
|
51
|
+
/** Status label shown when a render tool finishes (e.g. `"Chart ready"`). */
|
|
52
|
+
invoked?: string;
|
|
53
|
+
/** Status label shown while a render tool runs (e.g. `"Plotting chart…"`). */
|
|
54
|
+
invoking?: string;
|
|
55
|
+
/** Tool name (unqualified). The wire id is `<server>__<name>`. */
|
|
56
|
+
name: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The `requires` block as an AUTHOR writes it: both members optional. Distinct
|
|
61
|
+
* from the parsed {@link Requires} (where zod has applied its `[]` defaults, so
|
|
62
|
+
* both are present).
|
|
63
|
+
*/
|
|
64
|
+
export interface DefineAppRequires {
|
|
65
|
+
/** Plugins that must be installed + enabled before this app enables. */
|
|
66
|
+
apps?: Requires["apps"];
|
|
67
|
+
/**
|
|
68
|
+
* Abstract capability edges the broker binds to a provider at enable time
|
|
69
|
+
* (`[{ capability: "rag" }]`). This is what composable agent slots lower to.
|
|
70
|
+
*/
|
|
71
|
+
capabilities?: Requires["capabilities"];
|
|
72
|
+
/** Grants implied by those dependencies (declaration only). */
|
|
73
|
+
grants?: string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Options for {@link defineApp}. */
|
|
77
|
+
export interface DefineAppOptions {
|
|
78
|
+
/** VS-Code-style activation events. Empty = eager (default `["*"]`). */
|
|
79
|
+
activationEvents?: string[];
|
|
80
|
+
/** Default widget display mode (`inline` | `fullscreen` | `pip`). */
|
|
81
|
+
displayMode?: string;
|
|
82
|
+
/** Permission grants the app declares it needs (e.g. `["mcp:web_search"]`). */
|
|
83
|
+
grants?: string[];
|
|
84
|
+
/** Reverse-domain plugin id (e.g. `"com.example.checklist"`). */
|
|
85
|
+
id: string;
|
|
86
|
+
/** Widget MIME dialect. Defaults to `text/html+skybridge`. */
|
|
87
|
+
mime?: string;
|
|
88
|
+
/**
|
|
89
|
+
* Plugin-to-plugin dependencies. Core auto-enables them (in dependency order)
|
|
90
|
+
* before this app, and refuses to disable one while this app still needs it.
|
|
91
|
+
* Omit for the common case (no dependencies) — the key is then absent from the
|
|
92
|
+
* emitted manifest entirely.
|
|
93
|
+
*/
|
|
94
|
+
requires?: DefineAppRequires;
|
|
95
|
+
/** MCP server namespace for the tool ids. Defaults to `slug`. */
|
|
96
|
+
server?: string;
|
|
97
|
+
/**
|
|
98
|
+
* App slug — used to build the widget uri (`ui://widget/<slug>.html`) and, when
|
|
99
|
+
* `server` is omitted, the MCP server namespace that qualifies each tool id.
|
|
100
|
+
*/
|
|
101
|
+
slug: string;
|
|
102
|
+
/**
|
|
103
|
+
* Host surfaces this app runs on. **Omitted/empty = every surface** (the
|
|
104
|
+
* backward-compatible default); it never means "hidden".
|
|
105
|
+
*/
|
|
106
|
+
targets?: Surface[];
|
|
107
|
+
/** Human-readable display name shown in the app store / launcher. */
|
|
108
|
+
title: string;
|
|
109
|
+
/** The tools this app exposes (at least one render tool is expected). */
|
|
110
|
+
tools: AppToolSpec[];
|
|
111
|
+
/**
|
|
112
|
+
* Source entry (relative to the manifest dir) for the widget UI. `ryu pack`
|
|
113
|
+
* bundles it into the manifest's `ui_code` so Core can serve the widget HTML.
|
|
114
|
+
*/
|
|
115
|
+
uiEntry: string;
|
|
116
|
+
/** Semver version string (e.g. `"1.0.0"`). */
|
|
117
|
+
version: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Build a fully-qualified tool id from a server namespace and tool name. */
|
|
121
|
+
export function appToolId(server: string, name: string): string {
|
|
122
|
+
return `${server}__${name}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Assemble a `plugin.json` manifest for a Ryu App. The result matches Core's
|
|
127
|
+
* `PluginManifest` serde shape (validated through `PluginManifestSchema`) and can
|
|
128
|
+
* be written to disk, packed with `ryu pack`, or published with `ryu publish`.
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* ```ts
|
|
132
|
+
* import { defineApp } from "@ryuhq/sdk"
|
|
133
|
+
*
|
|
134
|
+
* const manifest = defineApp({
|
|
135
|
+
* id: "com.example.checklist",
|
|
136
|
+
* title: "Checklist",
|
|
137
|
+
* version: "1.0.0",
|
|
138
|
+
* slug: "checklist",
|
|
139
|
+
* uiEntry: "src/checklist.tsx",
|
|
140
|
+
* tools: [
|
|
141
|
+
* { name: "render", description: "Render a checklist", invoking: "Building…", invoked: "Ready" },
|
|
142
|
+
* { name: "toggle", description: "Toggle an item", accessible: true },
|
|
143
|
+
* ],
|
|
144
|
+
* })
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
export function defineApp(options: DefineAppOptions): PluginManifest {
|
|
148
|
+
const server = options.server ?? options.slug;
|
|
149
|
+
const uri = `ui://widget/${options.slug}.html`;
|
|
150
|
+
const mime = options.mime ?? DEFAULT_APP_WIDGET_MIME;
|
|
151
|
+
const displayMode = options.displayMode ?? DEFAULT_APP_DISPLAY_MODE;
|
|
152
|
+
// Whether the app declares any companion tool. A render tool's widget may call
|
|
153
|
+
// tools only when the app has at least one companion — mirrors `has_companions`
|
|
154
|
+
// in Core's `apps::tools()`.
|
|
155
|
+
const hasCompanions = options.tools.some((t) => t.accessible === true);
|
|
156
|
+
|
|
157
|
+
const runnables: RunnableMeta[] = [];
|
|
158
|
+
const widgets: WidgetContribution[] = [];
|
|
159
|
+
|
|
160
|
+
for (const spec of options.tools) {
|
|
161
|
+
const isRender = spec.accessible !== true;
|
|
162
|
+
const id = appToolId(server, spec.name);
|
|
163
|
+
|
|
164
|
+
const config: ToolAppConfig = {
|
|
165
|
+
slug: id,
|
|
166
|
+
description: spec.description,
|
|
167
|
+
widget: isRender,
|
|
168
|
+
widget_accessible: isRender ? hasCompanions : true,
|
|
169
|
+
...(spec.inputSchema ? { input_schema: spec.inputSchema } : {}),
|
|
170
|
+
...(spec.invoking ? { invoking: spec.invoking } : {}),
|
|
171
|
+
...(spec.invoked ? { invoked: spec.invoked } : {}),
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
runnables.push({
|
|
175
|
+
id,
|
|
176
|
+
name: spec.name,
|
|
177
|
+
kind: "tool",
|
|
178
|
+
config,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (isRender) {
|
|
182
|
+
widgets.push({
|
|
183
|
+
tool_id: id,
|
|
184
|
+
uri,
|
|
185
|
+
ui_entry: options.uiEntry,
|
|
186
|
+
mime,
|
|
187
|
+
default_display_mode: displayMode,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const contributes: Contributes = {
|
|
193
|
+
turn_hooks: [],
|
|
194
|
+
composer_controls: [],
|
|
195
|
+
settings_tabs: [],
|
|
196
|
+
slash_commands: [],
|
|
197
|
+
widgets,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const raw = {
|
|
201
|
+
id: options.id,
|
|
202
|
+
name: options.title,
|
|
203
|
+
version: options.version,
|
|
204
|
+
runnables,
|
|
205
|
+
permission_grants: options.grants ?? [],
|
|
206
|
+
activation_events: options.activationEvents ?? ["*"],
|
|
207
|
+
contributes,
|
|
208
|
+
// `targets: []` means EVERY surface, so an app that declares none is
|
|
209
|
+
// unrestricted — the backward-compatible default.
|
|
210
|
+
targets: options.targets ?? [],
|
|
211
|
+
// `requires` stays ABSENT (not `{apps:[],grants:[]}`) when undeclared, so the
|
|
212
|
+
// emitted manifest carries no key at all — matching Core's
|
|
213
|
+
// `Option<Requires>` + `skip_serializing_if = "Option::is_none"`.
|
|
214
|
+
...(options.requires
|
|
215
|
+
? {
|
|
216
|
+
requires: {
|
|
217
|
+
apps: options.requires.apps ?? [],
|
|
218
|
+
capabilities: options.requires.capabilities ?? [],
|
|
219
|
+
grants: options.requires.grants ?? [],
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
: {}),
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const result = PluginManifestSchema.safeParse(raw);
|
|
226
|
+
if (!result.success) {
|
|
227
|
+
const first = result.error.issues[0];
|
|
228
|
+
const field = first?.path.join(".") ?? "unknown";
|
|
229
|
+
const message = first?.message ?? "validation failed";
|
|
230
|
+
throw new Error(`plugin.json validation failed at '${field}': ${message}`);
|
|
231
|
+
}
|
|
232
|
+
return result.data;
|
|
233
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runnable — the single contract unifying Agent, Workflow, Tool, and Skill
|
|
3
|
+
* in the Ryu SDK.
|
|
4
|
+
*
|
|
5
|
+
* Design rules (from the M8 spike doc packages/sdk/README.md):
|
|
6
|
+
* - An agent may invoke a workflow as a named tool.
|
|
7
|
+
* - A workflow may orchestrate agents as steps.
|
|
8
|
+
* - All model calls MUST go through `ctx.gateway` — never a direct provider.
|
|
9
|
+
* - The four kinds are peers, not a hierarchy.
|
|
10
|
+
*
|
|
11
|
+
* This module re-exports all factory functions and types so consumers can
|
|
12
|
+
* import from `@ryuhq/sdk/runnable` as a single entry point.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type {
|
|
16
|
+
AgentCard,
|
|
17
|
+
AgentManifestOptions,
|
|
18
|
+
AgentOptions,
|
|
19
|
+
AgentRunnable,
|
|
20
|
+
AgentSlots,
|
|
21
|
+
CapabilitySlot,
|
|
22
|
+
ChatSlot,
|
|
23
|
+
} from "./agent.ts";
|
|
24
|
+
// biome-ignore lint/performance/noBarrelFile: intentional package entry point for @ryuhq/sdk/runnable
|
|
25
|
+
export { defineAgent } from "./agent.ts";
|
|
26
|
+
export type { AppToolSpec, DefineAppOptions } from "./app.ts";
|
|
27
|
+
export { appToolId, defineApp } from "./app.ts";
|
|
28
|
+
export type {
|
|
29
|
+
DurableClient,
|
|
30
|
+
EnginesClient,
|
|
31
|
+
HttpPrimitiveTransportOptions,
|
|
32
|
+
ImageClient,
|
|
33
|
+
MemoryClient,
|
|
34
|
+
MemoryItem,
|
|
35
|
+
PrimitiveBinding,
|
|
36
|
+
PrimitiveTransport,
|
|
37
|
+
RagChunk,
|
|
38
|
+
RagClient,
|
|
39
|
+
RagRerankResult,
|
|
40
|
+
RealtimeClient,
|
|
41
|
+
RealtimeSubscription,
|
|
42
|
+
RyuPrimitives,
|
|
43
|
+
SttClient,
|
|
44
|
+
TtsClient,
|
|
45
|
+
} from "./primitives.ts";
|
|
46
|
+
export {
|
|
47
|
+
createPrimitives,
|
|
48
|
+
httpPrimitiveTransport,
|
|
49
|
+
PRIMITIVE_BINDINGS,
|
|
50
|
+
} from "./primitives.ts";
|
|
51
|
+
export type {
|
|
52
|
+
GatewayClient,
|
|
53
|
+
Runnable,
|
|
54
|
+
RunnableContext,
|
|
55
|
+
} from "./runnable-types.ts";
|
|
56
|
+
export type { SkillOptions } from "./skill.ts";
|
|
57
|
+
export { defineSkill } from "./skill.ts";
|
|
58
|
+
export type { JsonSchemaProperty, ToolOptions, ToolSchema } from "./tool.ts";
|
|
59
|
+
export { defineTool } from "./tool.ts";
|
|
60
|
+
export type { WorkflowOptions, WorkflowStep } from "./workflow.ts";
|
|
61
|
+
export { defineWorkflow } from "./workflow.ts";
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lockstep guard: every SDK primitive that binds to a host-bridge RPC method must
|
|
3
|
+
* name a method that actually exists in the blessed host-API contract, with the
|
|
4
|
+
* SAME grant.
|
|
5
|
+
*
|
|
6
|
+
* `PRIMITIVE_BINDINGS` (`primitives.ts`) is the SDK's mirror of the host↔plugin
|
|
7
|
+
* method vocabulary. The canonical vocabulary now lives in
|
|
8
|
+
* `crates/ryu-kernel-contracts/schemas/host-api.json` (blessed from the Rust
|
|
9
|
+
* table; re-bless with `RYU_REGEN_SCHEMAS=1 cargo test -p ryu-kernel-contracts`).
|
|
10
|
+
* This test pins the `bridge`-transport bindings to that table so a renamed method
|
|
11
|
+
* or a drifted grant is caught here rather than at runtime. `direct` bindings hit
|
|
12
|
+
* Core data-path endpoints (not RPC methods) and `broker` bindings are abstract
|
|
13
|
+
* capabilities with no method yet, so only `bridge` bindings are checked.
|
|
14
|
+
*
|
|
15
|
+
* Deterministic, filesystem-only, no network.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, expect, test } from "bun:test";
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { PRIMITIVE_BINDINGS } from "./primitives";
|
|
22
|
+
|
|
23
|
+
const HOST_API_PATH = join(
|
|
24
|
+
import.meta.dir,
|
|
25
|
+
"../../../../crates/core/kernel-contracts/schemas/host-api.json"
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
interface HostApiMethodEntry {
|
|
29
|
+
capability: string;
|
|
30
|
+
grant: string | null;
|
|
31
|
+
method: string;
|
|
32
|
+
streaming: boolean;
|
|
33
|
+
tsHost: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const contract = JSON.parse(readFileSync(HOST_API_PATH, "utf8")) as {
|
|
37
|
+
version: string;
|
|
38
|
+
methods: HostApiMethodEntry[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const grantByMethod = new Map<string, string | null>(
|
|
42
|
+
contract.methods.map((m) => [m.method, m.grant])
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
describe("PRIMITIVE_BINDINGS lockstep with the blessed host-API contract", () => {
|
|
46
|
+
test("every bridge binding names a method present in the contract", () => {
|
|
47
|
+
for (const [name, binding] of Object.entries(PRIMITIVE_BINDINGS)) {
|
|
48
|
+
if (binding.transport !== "bridge") {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
expect(
|
|
52
|
+
grantByMethod.has(binding.method),
|
|
53
|
+
`${name} → method "${binding.method}" missing from host-api.json`
|
|
54
|
+
).toBe(true);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("every bridge binding's grant matches the contract's grant for that method", () => {
|
|
59
|
+
for (const [name, binding] of Object.entries(PRIMITIVE_BINDINGS)) {
|
|
60
|
+
if (binding.transport !== "bridge") {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
expect(
|
|
64
|
+
grantByMethod.get(binding.method),
|
|
65
|
+
`${name} → grant drift for "${binding.method}"`
|
|
66
|
+
).toBe(binding.grant);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("the contract exposes at least one method (sanity)", () => {
|
|
71
|
+
expect(contract.methods.length).toBeGreaterThan(0);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the composable primitive surface (program §6b).
|
|
3
|
+
*
|
|
4
|
+
* These verify the SDK-side contract WITHOUT a live Core node: a fake transport
|
|
5
|
+
* records which op each primitive method routes to, so we assert the vocabulary
|
|
6
|
+
* mirror (bridge/direct/broker + method/path/capability) matches `rpc.ts`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import { PluginManifestSchema, validateManifestStrict } from "../manifest.ts";
|
|
11
|
+
import { defineAgent } from "./agent.ts";
|
|
12
|
+
import {
|
|
13
|
+
createPrimitives,
|
|
14
|
+
httpPrimitiveTransport,
|
|
15
|
+
PRIMITIVE_BINDINGS,
|
|
16
|
+
type PrimitiveTransport,
|
|
17
|
+
} from "./primitives.ts";
|
|
18
|
+
|
|
19
|
+
interface Call {
|
|
20
|
+
body: unknown;
|
|
21
|
+
op: "bridge" | "direct" | "capability";
|
|
22
|
+
target: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function recordingTransport(): {
|
|
26
|
+
transport: PrimitiveTransport;
|
|
27
|
+
calls: Call[];
|
|
28
|
+
} {
|
|
29
|
+
const calls: Call[] = [];
|
|
30
|
+
const transport: PrimitiveTransport = {
|
|
31
|
+
bridge(method, args) {
|
|
32
|
+
calls.push({ op: "bridge", target: method, body: args });
|
|
33
|
+
return Promise.resolve("bridge-ok");
|
|
34
|
+
},
|
|
35
|
+
direct(path, body) {
|
|
36
|
+
calls.push({ op: "direct", target: path, body });
|
|
37
|
+
return Promise.resolve(["direct-ok"]);
|
|
38
|
+
},
|
|
39
|
+
capability(cap, body) {
|
|
40
|
+
calls.push({ op: "capability", target: cap, body });
|
|
41
|
+
return Promise.resolve([]);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
return { transport, calls };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("createPrimitives — transport routing mirrors rpc.ts", () => {
|
|
48
|
+
it("engines.complete routes through the bridge model.complete family", async () => {
|
|
49
|
+
const { transport, calls } = recordingTransport();
|
|
50
|
+
const p = createPrimitives(transport);
|
|
51
|
+
await p.engines.complete({ prompt: "hi", modelPrefKey: "chat" });
|
|
52
|
+
expect(calls).toHaveLength(1);
|
|
53
|
+
const [call] = calls;
|
|
54
|
+
if (!call) {
|
|
55
|
+
throw new Error("expected one call");
|
|
56
|
+
}
|
|
57
|
+
expect(call.op).toBe("bridge");
|
|
58
|
+
expect(call.target).toBe("model.complete");
|
|
59
|
+
// camelCase modelPrefKey lowers to the snake_case wire key.
|
|
60
|
+
expect(call.body).toMatchObject({ prompt: "hi", model_pref_key: "chat" });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("media primitives route host-direct to their Core endpoints", async () => {
|
|
64
|
+
const { transport, calls } = recordingTransport();
|
|
65
|
+
const p = createPrimitives(transport);
|
|
66
|
+
await p.image.generate({ prompt: "a cat" });
|
|
67
|
+
await p.tts.speak({ text: "hello" });
|
|
68
|
+
await p.stt.transcribe({ audio: "data:audio/wav;base64,AAAA" });
|
|
69
|
+
expect(calls.map((c) => `${c.op}:${c.target}`)).toEqual([
|
|
70
|
+
"direct:/api/images/generate",
|
|
71
|
+
"direct:/api/voice/speak",
|
|
72
|
+
"direct:/api/voice/transcribe",
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("stt.transcribe over the real HTTP transport uploads a multipart `file` (not JSON)", async () => {
|
|
77
|
+
let seen: { url: string; init: RequestInit } | undefined;
|
|
78
|
+
const fetchImpl = ((url: string, init: RequestInit) => {
|
|
79
|
+
seen = { url, init };
|
|
80
|
+
return Promise.resolve(
|
|
81
|
+
new Response(JSON.stringify({ text: " hello world " }), {
|
|
82
|
+
headers: { "content-type": "application/json" },
|
|
83
|
+
})
|
|
84
|
+
);
|
|
85
|
+
}) as unknown as typeof fetch;
|
|
86
|
+
|
|
87
|
+
const transport = httpPrimitiveTransport({
|
|
88
|
+
nodeUrl: "http://127.0.0.1:7980",
|
|
89
|
+
token: "t0k",
|
|
90
|
+
fetchImpl,
|
|
91
|
+
});
|
|
92
|
+
const p = createPrimitives(transport);
|
|
93
|
+
const text = await p.stt.transcribe({
|
|
94
|
+
audio: "data:audio/wav;base64,QUFBQQ==",
|
|
95
|
+
filename: "clip.wav",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// The transcript is parsed from Core's `{ text }` JSON and trimmed.
|
|
99
|
+
expect(text).toBe("hello world");
|
|
100
|
+
if (!seen) {
|
|
101
|
+
throw new Error("expected a fetch call");
|
|
102
|
+
}
|
|
103
|
+
expect(seen.url).toBe("http://127.0.0.1:7980/api/voice/transcribe");
|
|
104
|
+
// A JSON body would guarantee a 400 from Core's Multipart extractor.
|
|
105
|
+
const { body, headers } = seen.init;
|
|
106
|
+
expect(body).toBeInstanceOf(FormData);
|
|
107
|
+
const file = (body as FormData).get("file");
|
|
108
|
+
expect(file).toBeInstanceOf(Blob);
|
|
109
|
+
// No JSON content-type — FormData must set its own multipart boundary.
|
|
110
|
+
const ct = new Headers(headers).get("content-type") ?? "";
|
|
111
|
+
expect(ct.includes("application/json")).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("tts.speak over the real HTTP transport returns a data: URL from audio/wav bytes", async () => {
|
|
115
|
+
let sentBody: unknown;
|
|
116
|
+
const fetchImpl = ((_url: string, init: RequestInit) => {
|
|
117
|
+
sentBody = init.body;
|
|
118
|
+
return Promise.resolve(
|
|
119
|
+
new Response(new Uint8Array([1, 2, 3, 4]), {
|
|
120
|
+
headers: { "content-type": "audio/wav" },
|
|
121
|
+
})
|
|
122
|
+
);
|
|
123
|
+
}) as unknown as typeof fetch;
|
|
124
|
+
|
|
125
|
+
const transport = httpPrimitiveTransport({
|
|
126
|
+
nodeUrl: "http://127.0.0.1:7980",
|
|
127
|
+
fetchImpl,
|
|
128
|
+
});
|
|
129
|
+
const p = createPrimitives(transport);
|
|
130
|
+
const url = await p.tts.speak({ text: "hi", voice: "alto" });
|
|
131
|
+
|
|
132
|
+
// JSON in (not FormData), data: URL out — the shipped type contract.
|
|
133
|
+
expect(typeof sentBody).toBe("string");
|
|
134
|
+
expect(JSON.parse(sentBody as string)).toMatchObject({
|
|
135
|
+
text: "hi",
|
|
136
|
+
voice: "alto",
|
|
137
|
+
});
|
|
138
|
+
expect(url.startsWith("data:audio/wav;base64,")).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("broker-backed primitives POST to the capability broker (@requires-grant)", async () => {
|
|
142
|
+
const { transport, calls } = recordingTransport();
|
|
143
|
+
const p = createPrimitives(transport);
|
|
144
|
+
await p.rag.retrieve({ query: "q" });
|
|
145
|
+
await p.memory.recall({ query: "q" });
|
|
146
|
+
await p.realtime.broadcast({ room: "r", event: "e" });
|
|
147
|
+
await p.durable.checkpoint({ key: "k", state: {} });
|
|
148
|
+
await p.engines.embed({ input: "x" });
|
|
149
|
+
expect(calls.map((c) => c.target)).toEqual([
|
|
150
|
+
"rag",
|
|
151
|
+
"memory",
|
|
152
|
+
"realtime",
|
|
153
|
+
"durable",
|
|
154
|
+
"engines",
|
|
155
|
+
]);
|
|
156
|
+
expect(calls.every((c) => c.op === "capability")).toBe(true);
|
|
157
|
+
// The broker body carries the discriminating op + input.
|
|
158
|
+
const [ragCall] = calls;
|
|
159
|
+
if (!ragCall) {
|
|
160
|
+
throw new Error("expected a rag call");
|
|
161
|
+
}
|
|
162
|
+
expect(ragCall.body).toMatchObject({ op: "retrieve" });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("every declared primitive has a binding entry (no silent drift)", () => {
|
|
166
|
+
const expected = [
|
|
167
|
+
"engines.complete",
|
|
168
|
+
"engines.embed",
|
|
169
|
+
"image.generate",
|
|
170
|
+
"tts.speak",
|
|
171
|
+
"stt.transcribe",
|
|
172
|
+
"rag.retrieve",
|
|
173
|
+
"rag.embed",
|
|
174
|
+
"rag.rerank",
|
|
175
|
+
"memory.recall",
|
|
176
|
+
"memory.store",
|
|
177
|
+
"realtime.broadcast",
|
|
178
|
+
"realtime.subscribe",
|
|
179
|
+
"durable.checkpoint",
|
|
180
|
+
"durable.resume",
|
|
181
|
+
];
|
|
182
|
+
for (const key of expected) {
|
|
183
|
+
expect(PRIMITIVE_BINDINGS[key]).toBeDefined();
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("defineAgent — composable slots lower to a valid manifest", () => {
|
|
189
|
+
it("classic signature is unchanged (back-compat)", async () => {
|
|
190
|
+
let seen = "";
|
|
191
|
+
const a = defineAgent<{ q: string }, string>({
|
|
192
|
+
id: "agent-classic",
|
|
193
|
+
name: "Classic",
|
|
194
|
+
run({ q }, ctx) {
|
|
195
|
+
seen = q;
|
|
196
|
+
return ctx.gateway
|
|
197
|
+
.chat([{ role: "user", content: q }])
|
|
198
|
+
.then((r) => r.content);
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
expect(a.kind).toBe("agent");
|
|
202
|
+
expect(a.card.capabilities).toEqual([]);
|
|
203
|
+
const ctx = {
|
|
204
|
+
gateway: {
|
|
205
|
+
chat: () => Promise.resolve({ content: "ok", finishReason: null }),
|
|
206
|
+
async *stream() {
|
|
207
|
+
/* unused */
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
const out = await a.run({ q: "hello" }, ctx);
|
|
212
|
+
expect(seen).toBe("hello");
|
|
213
|
+
expect(out).toBe("ok");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("slots lower to requires.capabilities + persona/model config", () => {
|
|
217
|
+
const cmo = defineAgent({
|
|
218
|
+
id: "agent-cmo",
|
|
219
|
+
name: "CMO",
|
|
220
|
+
chat: { model: "gpt-4o", persona: "You are a CMO." },
|
|
221
|
+
rag: true,
|
|
222
|
+
memory: { minVersion: "1.2.0" },
|
|
223
|
+
tts: "com.acme.elevenlabs",
|
|
224
|
+
});
|
|
225
|
+
expect(cmo.card.model).toBe("gpt-4o");
|
|
226
|
+
expect(cmo.card.persona).toBe("You are a CMO.");
|
|
227
|
+
expect(cmo.card.capabilities).toEqual([
|
|
228
|
+
{ capability: "rag" },
|
|
229
|
+
{ capability: "memory", min_version: "1.2.0" },
|
|
230
|
+
{ capability: "tts" },
|
|
231
|
+
]);
|
|
232
|
+
expect(cmo.card.providers.tts).toBe("com.acme.elevenlabs");
|
|
233
|
+
|
|
234
|
+
const manifest = cmo.toManifest({ id: "com.acme.cmo", version: "1.0.0" });
|
|
235
|
+
// Lowered manifest must pass the SDK's authoring schema…
|
|
236
|
+
expect(PluginManifestSchema.safeParse(manifest).success).toBe(true);
|
|
237
|
+
expect(manifest.requires?.capabilities).toEqual([
|
|
238
|
+
{ capability: "rag" },
|
|
239
|
+
{ capability: "memory", min_version: "1.2.0" },
|
|
240
|
+
{ capability: "tts" },
|
|
241
|
+
]);
|
|
242
|
+
const [agentMeta] = manifest.runnables;
|
|
243
|
+
if (!agentMeta) {
|
|
244
|
+
throw new Error("expected a lowered agent runnable");
|
|
245
|
+
}
|
|
246
|
+
expect(agentMeta.kind).toBe("agent");
|
|
247
|
+
expect(agentMeta.config).toMatchObject({
|
|
248
|
+
model: "gpt-4o",
|
|
249
|
+
persona: "You are a CMO.",
|
|
250
|
+
capability_providers: { tts: "com.acme.elevenlabs" },
|
|
251
|
+
});
|
|
252
|
+
// …and Core-strict validation (native addon when present; skip otherwise).
|
|
253
|
+
try {
|
|
254
|
+
validateManifestStrict(JSON.stringify(manifest));
|
|
255
|
+
} catch (err) {
|
|
256
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
257
|
+
if (!message.includes("@ryuhq/sdk-native")) {
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("a slot-only agent synthesizes a chat-slot default run", async () => {
|
|
264
|
+
const bot = defineAgent({
|
|
265
|
+
id: "agent-bot",
|
|
266
|
+
name: "Bot",
|
|
267
|
+
chat: { persona: "Be terse." },
|
|
268
|
+
});
|
|
269
|
+
const messages: { role: string; content: string }[] = [];
|
|
270
|
+
const ctx = {
|
|
271
|
+
gateway: {
|
|
272
|
+
chat: (m: { role: string; content: string }[]) => {
|
|
273
|
+
messages.push(...m);
|
|
274
|
+
return Promise.resolve({ content: "reply", finishReason: null });
|
|
275
|
+
},
|
|
276
|
+
async *stream() {
|
|
277
|
+
/* unused */
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
const out = await bot.run("ping", ctx);
|
|
282
|
+
expect(out).toBe("reply");
|
|
283
|
+
expect(messages[0]).toEqual({ role: "system", content: "Be terse." });
|
|
284
|
+
expect(messages[1]).toEqual({ role: "user", content: "ping" });
|
|
285
|
+
});
|
|
286
|
+
});
|