@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
package/src/builder.ts
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ryu SDK typed builders — one builder per RunnableKind plus a PluginBuilder that
|
|
3
|
+
* assembles a complete, validated `plugin.json` manifest.
|
|
4
|
+
*
|
|
5
|
+
* Each builder follows a fluent interface: construct, chain setter calls, then
|
|
6
|
+
* call `.build()` to get a validated result. Invalid manifests throw a
|
|
7
|
+
* descriptive `Error` — never a silent fallback.
|
|
8
|
+
*
|
|
9
|
+
* Engine/model fields are typed as `string` throughout. No provider union is
|
|
10
|
+
* used so adding a new provider never requires an SDK change.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
AppDependency,
|
|
15
|
+
CapabilityReq,
|
|
16
|
+
CompanionSurface,
|
|
17
|
+
PluginManifest,
|
|
18
|
+
RunnableMeta,
|
|
19
|
+
Surface,
|
|
20
|
+
} from "./manifest.ts";
|
|
21
|
+
import { PluginManifestSchema, RunnableMetaSchema } from "./manifest.ts";
|
|
22
|
+
import type { AppToolSpec, DefineAppOptions } from "./runnable/app.ts";
|
|
23
|
+
import { defineApp } from "./runnable/app.ts";
|
|
24
|
+
|
|
25
|
+
// ── RunnableMeta builders ─────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/** Base builder shared by all Runnable kinds. */
|
|
28
|
+
class RunnableBuilder {
|
|
29
|
+
protected _id = "";
|
|
30
|
+
protected _name = "";
|
|
31
|
+
|
|
32
|
+
id(value: string): this {
|
|
33
|
+
this._id = value;
|
|
34
|
+
return this;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
name(value: string): this {
|
|
38
|
+
this._name = value;
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Builds an Agent `RunnableMeta` entry. */
|
|
44
|
+
export class AgentBuilder extends RunnableBuilder {
|
|
45
|
+
build(): RunnableMeta {
|
|
46
|
+
const result = RunnableMetaSchema.safeParse({
|
|
47
|
+
id: this._id,
|
|
48
|
+
name: this._name,
|
|
49
|
+
kind: "agent",
|
|
50
|
+
});
|
|
51
|
+
if (!result.success) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Invalid agent runnable: ${result.error.issues.map((i) => i.message).join("; ")}`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return result.data;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Builds a Workflow `RunnableMeta` entry. */
|
|
61
|
+
export class WorkflowBuilder extends RunnableBuilder {
|
|
62
|
+
build(): RunnableMeta {
|
|
63
|
+
const result = RunnableMetaSchema.safeParse({
|
|
64
|
+
id: this._id,
|
|
65
|
+
name: this._name,
|
|
66
|
+
kind: "workflow",
|
|
67
|
+
});
|
|
68
|
+
if (!result.success) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Invalid workflow runnable: ${result.error.issues.map((i) => i.message).join("; ")}`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return result.data;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Builds a Tool `RunnableMeta` entry. */
|
|
78
|
+
export class ToolBuilder extends RunnableBuilder {
|
|
79
|
+
build(): RunnableMeta {
|
|
80
|
+
const result = RunnableMetaSchema.safeParse({
|
|
81
|
+
id: this._id,
|
|
82
|
+
name: this._name,
|
|
83
|
+
kind: "tool",
|
|
84
|
+
});
|
|
85
|
+
if (!result.success) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Invalid tool runnable: ${result.error.issues.map((i) => i.message).join("; ")}`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return result.data;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Builds a Skill `RunnableMeta` entry. */
|
|
95
|
+
export class SkillBuilder extends RunnableBuilder {
|
|
96
|
+
build(): RunnableMeta {
|
|
97
|
+
const result = RunnableMetaSchema.safeParse({
|
|
98
|
+
id: this._id,
|
|
99
|
+
name: this._name,
|
|
100
|
+
kind: "skill",
|
|
101
|
+
});
|
|
102
|
+
if (!result.success) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Invalid skill runnable: ${result.error.issues.map((i) => i.message).join("; ")}`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return result.data;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Convenience factory functions ─────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
/** Create an AgentBuilder. */
|
|
114
|
+
export const agent = () => new AgentBuilder();
|
|
115
|
+
|
|
116
|
+
/** Create a WorkflowBuilder. */
|
|
117
|
+
export const workflow = () => new WorkflowBuilder();
|
|
118
|
+
|
|
119
|
+
/** Create a ToolBuilder. */
|
|
120
|
+
export const tool = () => new ToolBuilder();
|
|
121
|
+
|
|
122
|
+
/** Create a SkillBuilder. */
|
|
123
|
+
export const skill = () => new SkillBuilder();
|
|
124
|
+
|
|
125
|
+
// ── PluginBuilder ─────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Fluent builder for a complete `plugin.json` Plugin manifest. Produces a
|
|
129
|
+
* validated `PluginManifest` on `.build()` or throws a descriptive `Error`
|
|
130
|
+
* naming the first invalid field.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* import { PluginBuilder, agent, tool } from "@ryuhq/sdk/builder"
|
|
135
|
+
*
|
|
136
|
+
* const manifest = new PluginBuilder()
|
|
137
|
+
* .id("com.example.my-plugin")
|
|
138
|
+
* .name("My Plugin")
|
|
139
|
+
* .version("1.0.0")
|
|
140
|
+
* .runnable(agent().id("agent-main").name("Main Agent").build())
|
|
141
|
+
* .runnable(tool().id("tool-search").name("Web Search").build())
|
|
142
|
+
* .grant("mcp:web_search")
|
|
143
|
+
* .companion({ label: "My Plugin", icon: "sparkles", shortcut: "ctrl+shift+m" })
|
|
144
|
+
* .build()
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
export class PluginBuilder {
|
|
148
|
+
private _id = "";
|
|
149
|
+
private _name = "";
|
|
150
|
+
private _version = "";
|
|
151
|
+
private readonly _runnables: RunnableMeta[] = [];
|
|
152
|
+
private readonly _grants: string[] = [];
|
|
153
|
+
private _companion: CompanionSurface | undefined = undefined;
|
|
154
|
+
private readonly _dependencies: AppDependency[] = [];
|
|
155
|
+
private readonly _requiredCapabilities: CapabilityReq[] = [];
|
|
156
|
+
private readonly _requiredGrants: string[] = [];
|
|
157
|
+
private readonly _targets: Surface[] = [];
|
|
158
|
+
|
|
159
|
+
/** Set the reverse-domain app id (e.g. `"com.example.my-app"`). */
|
|
160
|
+
id(value: string): this {
|
|
161
|
+
this._id = value;
|
|
162
|
+
return this;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Set the human-readable display name. */
|
|
166
|
+
name(value: string): this {
|
|
167
|
+
this._name = value;
|
|
168
|
+
return this;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Set the semver version string (e.g. `"1.0.0"`). */
|
|
172
|
+
version(value: string): this {
|
|
173
|
+
this._version = value;
|
|
174
|
+
return this;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Append a pre-built `RunnableMeta` (from any per-kind builder). */
|
|
178
|
+
runnable(meta: RunnableMeta): this {
|
|
179
|
+
this._runnables.push(meta);
|
|
180
|
+
return this;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Declare a permission grant (e.g. `"mcp:web_search"`). */
|
|
184
|
+
grant(permission: string): this {
|
|
185
|
+
this._grants.push(permission);
|
|
186
|
+
return this;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Set an optional Companion surface descriptor. */
|
|
190
|
+
companion(surface: CompanionSurface): this {
|
|
191
|
+
this._companion = surface;
|
|
192
|
+
return this;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Declare a **plugin-to-plugin dependency**: `id` must be installed and is
|
|
197
|
+
* auto-enabled (in dependency order) before this plugin enables.
|
|
198
|
+
*
|
|
199
|
+
* `minVersion` is a MINIMUM — a bare `"1.2.0"` means `">=1.2.0"`, so an
|
|
200
|
+
* installed `2.0.0` satisfies it (comparator syntax like `">=1.2, <2"` is
|
|
201
|
+
* honoured verbatim).
|
|
202
|
+
*/
|
|
203
|
+
dependsOn(id: string, minVersion?: string): this {
|
|
204
|
+
this._dependencies.push(
|
|
205
|
+
minVersion ? { id, min_version: minVersion } : { id }
|
|
206
|
+
);
|
|
207
|
+
return this;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Declare a permission grant implied by this plugin's dependencies
|
|
212
|
+
* (`requires.grants`). Declaration only — the Gateway remains the sole
|
|
213
|
+
* authority on what a grant allows. Use {@link PluginBuilder.grant} for the
|
|
214
|
+
* grants this plugin needs in its own right.
|
|
215
|
+
*/
|
|
216
|
+
requiredGrant(permission: string): this {
|
|
217
|
+
this._requiredGrants.push(permission);
|
|
218
|
+
return this;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Declare an abstract **capability** edge (`requires.capabilities`) the broker
|
|
223
|
+
* resolves to a bound provider at enable time — e.g. `requiresCapability("rag")`.
|
|
224
|
+
* Distinct from a specific-plugin dependency: a capability edge lets the
|
|
225
|
+
* binding registry choose the provider. `minVersion` is a MINIMUM (`"1.2.0"`
|
|
226
|
+
* = `">=1.2.0"`).
|
|
227
|
+
*/
|
|
228
|
+
requiresCapability(capability: string, minVersion?: string): this {
|
|
229
|
+
this._requiredCapabilities.push(
|
|
230
|
+
minVersion ? { capability, min_version: minVersion } : { capability }
|
|
231
|
+
);
|
|
232
|
+
return this;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Restrict this plugin to a host surface (`"desktop"`, `"island"`, …).
|
|
237
|
+
* Declaring NO target is the default and means **every** surface.
|
|
238
|
+
*/
|
|
239
|
+
target(surface: Surface): this {
|
|
240
|
+
this._targets.push(surface);
|
|
241
|
+
return this;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Validate and return the assembled `PluginManifest`. Throws an `Error` with
|
|
246
|
+
* the failing field name and message when validation fails.
|
|
247
|
+
*/
|
|
248
|
+
build(): PluginManifest {
|
|
249
|
+
// `requires` is omitted entirely when nothing was declared, so a manifest
|
|
250
|
+
// with no dependencies serialises with no `requires` key — matching Core's
|
|
251
|
+
// `Option<Requires>` + `skip_serializing_if`.
|
|
252
|
+
const hasRequires =
|
|
253
|
+
this._dependencies.length > 0 ||
|
|
254
|
+
this._requiredCapabilities.length > 0 ||
|
|
255
|
+
this._requiredGrants.length > 0;
|
|
256
|
+
|
|
257
|
+
const raw = {
|
|
258
|
+
id: this._id,
|
|
259
|
+
name: this._name,
|
|
260
|
+
version: this._version,
|
|
261
|
+
runnables: this._runnables,
|
|
262
|
+
permission_grants: this._grants,
|
|
263
|
+
companion: this._companion,
|
|
264
|
+
targets: this._targets,
|
|
265
|
+
...(hasRequires
|
|
266
|
+
? {
|
|
267
|
+
requires: {
|
|
268
|
+
apps: this._dependencies,
|
|
269
|
+
capabilities: this._requiredCapabilities,
|
|
270
|
+
grants: this._requiredGrants,
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
: {}),
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const result = PluginManifestSchema.safeParse(raw);
|
|
277
|
+
if (!result.success) {
|
|
278
|
+
const first = result.error.issues[0];
|
|
279
|
+
const field = first?.path.join(".") ?? "unknown";
|
|
280
|
+
const message = first?.message ?? "validation failed";
|
|
281
|
+
throw new Error(
|
|
282
|
+
`plugin.json validation failed at '${field}': ${message}`
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
return result.data;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── AppBuilder (Ryu Apps) ─────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Fluent builder for a Ryu App — a `plugin.json` whose tools render interactive
|
|
293
|
+
* widgets inline in chat. Delegates to {@link defineApp} on `.build()`, so it
|
|
294
|
+
* derives the render-vs-companion split and validates through
|
|
295
|
+
* `PluginManifestSchema` (throwing a descriptive `Error` on bad input) exactly
|
|
296
|
+
* like the factory.
|
|
297
|
+
*
|
|
298
|
+
* @example
|
|
299
|
+
* ```ts
|
|
300
|
+
* import { app } from "@ryuhq/sdk/builder"
|
|
301
|
+
*
|
|
302
|
+
* const manifest = app()
|
|
303
|
+
* .id("com.example.checklist")
|
|
304
|
+
* .title("Checklist")
|
|
305
|
+
* .version("1.0.0")
|
|
306
|
+
* .slug("checklist")
|
|
307
|
+
* .uiEntry("src/checklist.tsx")
|
|
308
|
+
* .tool({ name: "render", description: "Render a checklist", invoking: "Building…" })
|
|
309
|
+
* .tool({ name: "toggle", description: "Toggle an item", accessible: true })
|
|
310
|
+
* .build()
|
|
311
|
+
* ```
|
|
312
|
+
*/
|
|
313
|
+
export class AppBuilder {
|
|
314
|
+
private _id = "";
|
|
315
|
+
private _title = "";
|
|
316
|
+
private _version = "";
|
|
317
|
+
private _slug = "";
|
|
318
|
+
private _server: string | undefined = undefined;
|
|
319
|
+
private _displayMode: string | undefined = undefined;
|
|
320
|
+
private _mime: string | undefined = undefined;
|
|
321
|
+
private _uiEntry = "";
|
|
322
|
+
private readonly _grants: string[] = [];
|
|
323
|
+
private readonly _activationEvents: string[] = [];
|
|
324
|
+
private readonly _tools: AppToolSpec[] = [];
|
|
325
|
+
private readonly _dependencies: AppDependency[] = [];
|
|
326
|
+
private readonly _requiredCapabilities: CapabilityReq[] = [];
|
|
327
|
+
private readonly _requiredGrants: string[] = [];
|
|
328
|
+
private readonly _targets: Surface[] = [];
|
|
329
|
+
|
|
330
|
+
/** Set the reverse-domain app id (e.g. `"com.example.checklist"`). */
|
|
331
|
+
id(value: string): this {
|
|
332
|
+
this._id = value;
|
|
333
|
+
return this;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Set the human-readable display name. */
|
|
337
|
+
title(value: string): this {
|
|
338
|
+
this._title = value;
|
|
339
|
+
return this;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Set the semver version string (e.g. `"1.0.0"`). */
|
|
343
|
+
version(value: string): this {
|
|
344
|
+
this._version = value;
|
|
345
|
+
return this;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Set the app slug (drives `ui://widget/<slug>.html` and the server default). */
|
|
349
|
+
slug(value: string): this {
|
|
350
|
+
this._slug = value;
|
|
351
|
+
return this;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Override the MCP server namespace for tool ids (defaults to the slug). */
|
|
355
|
+
server(value: string): this {
|
|
356
|
+
this._server = value;
|
|
357
|
+
return this;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Set the default widget display mode (`inline` | `fullscreen` | `pip`). */
|
|
361
|
+
displayMode(value: string): this {
|
|
362
|
+
this._displayMode = value;
|
|
363
|
+
return this;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Override the widget MIME dialect (defaults to `text/html+skybridge`). */
|
|
367
|
+
mime(value: string): this {
|
|
368
|
+
this._mime = value;
|
|
369
|
+
return this;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Set the widget UI source entry `ryu pack` bundles into `ui_code`. */
|
|
373
|
+
uiEntry(value: string): this {
|
|
374
|
+
this._uiEntry = value;
|
|
375
|
+
return this;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Declare a permission grant (e.g. `"mcp:web_search"`). */
|
|
379
|
+
grant(permission: string): this {
|
|
380
|
+
this._grants.push(permission);
|
|
381
|
+
return this;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Add a VS-Code-style activation event (empty = eager `["*"]`). */
|
|
385
|
+
activationEvent(event: string): this {
|
|
386
|
+
this._activationEvents.push(event);
|
|
387
|
+
return this;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Append a tool spec (render tool unless `accessible:true`). */
|
|
391
|
+
tool(spec: AppToolSpec): this {
|
|
392
|
+
this._tools.push(spec);
|
|
393
|
+
return this;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Declare a **plugin-to-plugin dependency** (auto-enabled, in dependency order,
|
|
398
|
+
* before this app). `minVersion` is a MINIMUM (`"1.2.0"` = `">=1.2.0"`).
|
|
399
|
+
*/
|
|
400
|
+
dependsOn(id: string, minVersion?: string): this {
|
|
401
|
+
this._dependencies.push(
|
|
402
|
+
minVersion ? { id, min_version: minVersion } : { id }
|
|
403
|
+
);
|
|
404
|
+
return this;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Declare a grant implied by this app's dependencies (`requires.grants`). */
|
|
408
|
+
requiredGrant(permission: string): this {
|
|
409
|
+
this._requiredGrants.push(permission);
|
|
410
|
+
return this;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Declare an abstract **capability** edge (`requires.capabilities`) the broker
|
|
415
|
+
* resolves to a bound provider at enable time — e.g. `requiresCapability("rag")`.
|
|
416
|
+
* Distinct from a specific-plugin dependency: a capability edge lets the
|
|
417
|
+
* binding registry choose the provider. `minVersion` is a MINIMUM (`"1.2.0"`
|
|
418
|
+
* = `">=1.2.0"`).
|
|
419
|
+
*/
|
|
420
|
+
requiresCapability(capability: string, minVersion?: string): this {
|
|
421
|
+
this._requiredCapabilities.push(
|
|
422
|
+
minVersion ? { capability, min_version: minVersion } : { capability }
|
|
423
|
+
);
|
|
424
|
+
return this;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Restrict this app to a host surface. No target = every surface. */
|
|
428
|
+
target(surface: Surface): this {
|
|
429
|
+
this._targets.push(surface);
|
|
430
|
+
return this;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Validate and return the assembled `PluginManifest`. Throws an `Error` naming
|
|
435
|
+
* the failing field when validation fails.
|
|
436
|
+
*/
|
|
437
|
+
build(): PluginManifest {
|
|
438
|
+
const hasRequires =
|
|
439
|
+
this._dependencies.length > 0 ||
|
|
440
|
+
this._requiredCapabilities.length > 0 ||
|
|
441
|
+
this._requiredGrants.length > 0;
|
|
442
|
+
|
|
443
|
+
const options: DefineAppOptions = {
|
|
444
|
+
id: this._id,
|
|
445
|
+
title: this._title,
|
|
446
|
+
version: this._version,
|
|
447
|
+
slug: this._slug,
|
|
448
|
+
uiEntry: this._uiEntry,
|
|
449
|
+
tools: this._tools,
|
|
450
|
+
grants: this._grants,
|
|
451
|
+
...(this._server ? { server: this._server } : {}),
|
|
452
|
+
...(this._displayMode ? { displayMode: this._displayMode } : {}),
|
|
453
|
+
...(this._mime ? { mime: this._mime } : {}),
|
|
454
|
+
...(this._activationEvents.length > 0
|
|
455
|
+
? { activationEvents: this._activationEvents }
|
|
456
|
+
: {}),
|
|
457
|
+
...(hasRequires
|
|
458
|
+
? {
|
|
459
|
+
requires: {
|
|
460
|
+
apps: this._dependencies,
|
|
461
|
+
capabilities: this._requiredCapabilities,
|
|
462
|
+
grants: this._requiredGrants,
|
|
463
|
+
},
|
|
464
|
+
}
|
|
465
|
+
: {}),
|
|
466
|
+
...(this._targets.length > 0 ? { targets: this._targets } : {}),
|
|
467
|
+
};
|
|
468
|
+
return defineApp(options);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Create an AppBuilder. */
|
|
473
|
+
export const app = () => new AppBuilder();
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke test for `ryu dev` — runs a full turn against a mock gateway and a
|
|
3
|
+
* sample Runnable, asserting that text, tool-call, and tool-result events
|
|
4
|
+
* stream to stdout correctly.
|
|
5
|
+
*
|
|
6
|
+
* The mock gateway is an in-process HTTP server (Bun.serve) that returns a
|
|
7
|
+
* pre-canned SSE response so the test never needs a real gateway running.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
11
|
+
import { unlinkSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { serve } from "bun";
|
|
13
|
+
import type { ChatMessage } from "../model/client.ts";
|
|
14
|
+
import { ModelClient } from "../model/client.ts";
|
|
15
|
+
import type { DevEvent, Runnable } from "./dev.ts";
|
|
16
|
+
import { loadRunnable, probeGateway, runTurn } from "./dev.ts";
|
|
17
|
+
|
|
18
|
+
// ── Mock gateway ──────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/** Pre-canned SSE body the mock gateway returns for any chat completions POST. */
|
|
21
|
+
const MOCK_SSE_BODY = [
|
|
22
|
+
'data: {"choices":[{"delta":{"content":"Hello"},"finish_reason":null}]}',
|
|
23
|
+
'data: {"choices":[{"delta":{"content":", world"},"finish_reason":null}]}',
|
|
24
|
+
'data: {"choices":[{"delta":{"content":"!"},"finish_reason":"stop"}]}',
|
|
25
|
+
"data: [DONE]",
|
|
26
|
+
].join("\n");
|
|
27
|
+
|
|
28
|
+
let mockServer: ReturnType<typeof serve>;
|
|
29
|
+
let mockBaseUrl: string;
|
|
30
|
+
|
|
31
|
+
beforeAll(() => {
|
|
32
|
+
mockServer = serve({
|
|
33
|
+
port: 0, // OS-assigned port
|
|
34
|
+
fetch(req) {
|
|
35
|
+
const url = new URL(req.url);
|
|
36
|
+
|
|
37
|
+
if (url.pathname === "/health") {
|
|
38
|
+
return new Response("ok", { status: 200 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (url.pathname === "/v1/chat/completions") {
|
|
42
|
+
return new Response(MOCK_SSE_BODY, {
|
|
43
|
+
status: 200,
|
|
44
|
+
headers: { "Content-Type": "text/event-stream" },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return new Response("not found", { status: 404 });
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
mockBaseUrl = `http://127.0.0.1:${mockServer.port}`;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterAll(() => {
|
|
55
|
+
mockServer.stop();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ── Sample Runnable ───────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A minimal Runnable that streams assistant text by calling model.stream(),
|
|
62
|
+
* then emits a synthetic tool-call + tool-result pair so the test covers all
|
|
63
|
+
* event types.
|
|
64
|
+
*/
|
|
65
|
+
const sampleRunnable: Runnable = {
|
|
66
|
+
name: "smoke-test-agent",
|
|
67
|
+
async *run(
|
|
68
|
+
messages: ChatMessage[],
|
|
69
|
+
model: ModelClient
|
|
70
|
+
): AsyncGenerator<DevEvent> {
|
|
71
|
+
// Stream text from the gateway.
|
|
72
|
+
for await (const delta of model.stream(messages)) {
|
|
73
|
+
if (delta.content) {
|
|
74
|
+
yield { type: "text", content: delta.content };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Emit a synthetic tool call.
|
|
79
|
+
yield {
|
|
80
|
+
type: "tool_call",
|
|
81
|
+
id: "tc-1",
|
|
82
|
+
title: "web_search",
|
|
83
|
+
kind: "execute",
|
|
84
|
+
input: { query: "ryu sdk" },
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Emit a tool result.
|
|
88
|
+
yield {
|
|
89
|
+
type: "tool_result",
|
|
90
|
+
id: "tc-1",
|
|
91
|
+
status: "completed",
|
|
92
|
+
output: { results: ["https://ryu.dev"] },
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
describe("probeGateway", () => {
|
|
100
|
+
it("returns true when the gateway /health responds", async () => {
|
|
101
|
+
const reachable = await probeGateway(mockBaseUrl);
|
|
102
|
+
expect(reachable).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("returns false when the URL is unreachable", async () => {
|
|
106
|
+
const reachable = await probeGateway("http://127.0.0.1:1");
|
|
107
|
+
expect(reachable).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("loadRunnable", () => {
|
|
112
|
+
it("throws a descriptive error when the module has no runnable export", async () => {
|
|
113
|
+
// Write a temp module that exports neither "default" nor "runnable",
|
|
114
|
+
// then assert loadRunnable rejects with the expected message.
|
|
115
|
+
const tmpPath = `${import.meta.dir}/_tmp_no_runnable_${Date.now()}.ts`;
|
|
116
|
+
writeFileSync(tmpPath, "export const x = 1;\n", "utf8");
|
|
117
|
+
try {
|
|
118
|
+
await expect(loadRunnable(tmpPath)).rejects.toThrow(
|
|
119
|
+
"must export a Runnable"
|
|
120
|
+
);
|
|
121
|
+
} finally {
|
|
122
|
+
try {
|
|
123
|
+
unlinkSync(tmpPath);
|
|
124
|
+
} catch {
|
|
125
|
+
// ignore cleanup errors
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("runTurn — full turn streams to stdout", () => {
|
|
132
|
+
it("collects text + tool events from a full turn", async () => {
|
|
133
|
+
const model = new ModelClient("test-model", { baseUrl: mockBaseUrl });
|
|
134
|
+
|
|
135
|
+
const events: DevEvent[] = [];
|
|
136
|
+
|
|
137
|
+
// Wrap the sample runnable so we can capture events without relying on
|
|
138
|
+
// process.stdout parsing.
|
|
139
|
+
const capturingRunnable: Runnable = {
|
|
140
|
+
name: "capturing",
|
|
141
|
+
async *run(messages, mdl): AsyncGenerator<DevEvent> {
|
|
142
|
+
for await (const ev of sampleRunnable.run(messages, mdl)) {
|
|
143
|
+
events.push(ev);
|
|
144
|
+
yield ev;
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const ok = await runTurn(
|
|
150
|
+
capturingRunnable,
|
|
151
|
+
[{ role: "user", content: "hello" }],
|
|
152
|
+
model
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
expect(ok).toBe(true);
|
|
156
|
+
|
|
157
|
+
const textEvents = events.filter((e) => e.type === "text");
|
|
158
|
+
const toolCallEvents = events.filter((e) => e.type === "tool_call");
|
|
159
|
+
const toolResultEvents = events.filter((e) => e.type === "tool_result");
|
|
160
|
+
|
|
161
|
+
// Three text chunks from the mock SSE body.
|
|
162
|
+
expect(textEvents).toHaveLength(3);
|
|
163
|
+
const fullText = textEvents
|
|
164
|
+
.map((e) => (e as { content: string }).content)
|
|
165
|
+
.join("");
|
|
166
|
+
expect(fullText).toBe("Hello, world!");
|
|
167
|
+
|
|
168
|
+
// One tool-call event.
|
|
169
|
+
expect(toolCallEvents).toHaveLength(1);
|
|
170
|
+
expect((toolCallEvents[0] as { id: string }).id).toBe("tc-1");
|
|
171
|
+
|
|
172
|
+
// One tool-result event.
|
|
173
|
+
expect(toolResultEvents).toHaveLength(1);
|
|
174
|
+
expect((toolResultEvents[0] as { status: string }).status).toBe(
|
|
175
|
+
"completed"
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
});
|