@ryuhq/sdk 0.2.0 → 0.2.2
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/README.md +51 -2
- package/dist/action.cjs +839 -0
- package/dist/action.d.cts +88 -0
- package/dist/action.d.ts +88 -0
- package/dist/action.js +8 -0
- package/dist/agent-plugin.d.cts +1 -1
- package/dist/agent-plugin.d.ts +1 -1
- package/dist/agent.cjs +7 -0
- package/dist/agent.d.cts +1 -1
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +2 -2
- package/dist/{app-DNaGmLVf.d.cts → app-B0Z9Ew_R.d.cts} +18 -6
- package/dist/{app-Bkw7LlCK.d.ts → app-C-BDJwfG.d.ts} +18 -6
- package/dist/builder.cjs +110 -11
- package/dist/builder.d.cts +7 -2
- package/dist/builder.d.ts +7 -2
- package/dist/builder.js +4 -3
- package/dist/chunk-4TPUZDTI.js +94 -0
- package/dist/{chunk-T5676WL2.js → chunk-BC3A7HMO.js} +1 -77
- package/dist/{chunk-IOLP5FFE.js → chunk-HLKJZAFK.js} +9 -2
- package/dist/{chunk-IEUQ3CDG.js → chunk-NZKVOSC2.js} +71 -5
- package/dist/chunk-QYFUNJOH.js +83 -0
- package/dist/{chunk-W3KPP4WN.js → chunk-SN2QBJUF.js} +20 -7
- package/dist/{chunk-ULSVL7EC.js → chunk-Z57QDDJR.js} +1 -1
- package/dist/{chunk-A3RGEPDG.js → chunk-ZTJWBRUL.js} +32 -0
- package/dist/cli.cjs +93 -8
- package/dist/cli.js +33 -8
- package/dist/index.cjs +297 -87
- package/dist/index.d.cts +10 -9
- package/dist/index.d.ts +10 -9
- package/dist/index.js +34 -19
- package/dist/manifest.cjs +76 -6
- package/dist/manifest.d.cts +157 -5
- package/dist/manifest.d.ts +157 -5
- package/dist/manifest.js +9 -1
- package/dist/mcp/server.d.cts +2 -1
- package/dist/mcp/server.d.ts +2 -1
- package/dist/runnable.cjs +279 -71
- package/dist/runnable.d.cts +6 -3
- package/dist/runnable.d.ts +6 -3
- package/dist/runnable.js +11 -5
- package/dist/{tool-DSx2bFx8.d.ts → tool-AjkdFvhE.d.ts} +54 -4
- package/dist/{tool-u-VR0fLF.d.cts → tool-CgzW92O_.d.cts} +54 -4
- package/package.json +12 -3
- package/src/agent-plugin.ts +1 -1
- package/src/builder.ts +9 -0
- package/src/cli-security.test.ts +109 -0
- package/src/cli.ts +43 -10
- package/src/contracts-lockstep.test.ts +16 -2
- package/src/exports-lockstep.test.ts +1 -0
- package/src/generated/plugin-manifest.ts +82 -5
- package/src/index.ts +18 -0
- package/src/manifest.fixtures.test.ts +9 -3
- package/src/manifest.test.ts +69 -0
- package/src/manifest.ts +137 -18
- package/src/mcp/server.ts +2 -1
- package/src/runnable/action.test.ts +128 -0
- package/src/runnable/action.ts +202 -0
- package/src/runnable/app.ts +45 -12
- package/src/runnable/index.ts +18 -3
- package/src/runnable/primitives.test.ts +34 -0
- package/src/runnable/primitives.ts +59 -0
- package/src/runnable/runnable-types.ts +3 -0
- package/src/runnable/tool.ts +35 -4
- package/src/runnable/turn-hook.ts +4 -2
- package/src/slash-command.test.ts +69 -0
package/src/manifest.test.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { join } from "node:path";
|
|
|
26
26
|
import { agent, app, PluginBuilder, skill, tool, workflow } from "./builder.ts";
|
|
27
27
|
import { PluginManifestSchema } from "./manifest.ts";
|
|
28
28
|
import { defineApp } from "./runnable/app.ts";
|
|
29
|
+
import { defineTool } from "./runnable/tool.ts";
|
|
29
30
|
|
|
30
31
|
// ── builder unit tests ────────────────────────────────────────────────────────
|
|
31
32
|
|
|
@@ -363,6 +364,49 @@ describe("defineApp", () => {
|
|
|
363
364
|
);
|
|
364
365
|
});
|
|
365
366
|
|
|
367
|
+
it("embeds an executable ToolRunnable in the widget tool", () => {
|
|
368
|
+
const renderTool = defineTool({
|
|
369
|
+
id: "support.render",
|
|
370
|
+
name: "Support answer",
|
|
371
|
+
schema: {
|
|
372
|
+
type: "object",
|
|
373
|
+
properties: { message: { type: "string" } },
|
|
374
|
+
required: ["message"],
|
|
375
|
+
},
|
|
376
|
+
run: async (input) => ({
|
|
377
|
+
content: [{ type: "text", text: input.message }],
|
|
378
|
+
structuredContent: { answer: input.message },
|
|
379
|
+
}),
|
|
380
|
+
});
|
|
381
|
+
const manifest = defineApp({
|
|
382
|
+
id: "com.example.support",
|
|
383
|
+
title: "Support",
|
|
384
|
+
version: "1.0.0",
|
|
385
|
+
slug: "support",
|
|
386
|
+
uiEntry: "src/widget.html",
|
|
387
|
+
tools: [
|
|
388
|
+
{
|
|
389
|
+
name: "render",
|
|
390
|
+
description: "Answer a support question",
|
|
391
|
+
inputSchema: renderTool.schema as unknown as Record<string, unknown>,
|
|
392
|
+
runnable: renderTool,
|
|
393
|
+
},
|
|
394
|
+
],
|
|
395
|
+
});
|
|
396
|
+
const render = manifest.runnables[0];
|
|
397
|
+
|
|
398
|
+
expect(render?.config).toMatchObject({
|
|
399
|
+
backend: "inline_deno",
|
|
400
|
+
code: renderTool.code,
|
|
401
|
+
input_schema: renderTool.schema,
|
|
402
|
+
widget: true,
|
|
403
|
+
});
|
|
404
|
+
expect(manifest.permission_grants).toEqual([
|
|
405
|
+
"widget:render",
|
|
406
|
+
"tool:execute",
|
|
407
|
+
]);
|
|
408
|
+
});
|
|
409
|
+
|
|
366
410
|
it("round-trips through PluginManifestSchema without stripping widgets", () => {
|
|
367
411
|
// The load-bearing check: `contributes.widgets` is only preserved because it
|
|
368
412
|
// was added to `ContributesSchema`. A JSON round-trip proves the field
|
|
@@ -805,6 +849,31 @@ describe("contributes.message_actions", () => {
|
|
|
805
849
|
});
|
|
806
850
|
});
|
|
807
851
|
|
|
852
|
+
describe("contributes.selection_actions", () => {
|
|
853
|
+
it("preserves host-owned selection dispatch args through the pack-path parse", () => {
|
|
854
|
+
const action = {
|
|
855
|
+
args: { dispatch: "side-chat.selection", intent: "explain" },
|
|
856
|
+
id: "side-chats.explain-selection",
|
|
857
|
+
kind: "button",
|
|
858
|
+
label: "Explain",
|
|
859
|
+
order: 110,
|
|
860
|
+
};
|
|
861
|
+
const parsed = PluginManifestSchema.safeParse({
|
|
862
|
+
id: "com.example.side-chats",
|
|
863
|
+
name: "Side Chats",
|
|
864
|
+
version: "1.0.0",
|
|
865
|
+
runnables: [],
|
|
866
|
+
contributes: { selection_actions: [action] },
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
expect(parsed.success).toBe(true);
|
|
870
|
+
if (!parsed.success) {
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
expect(parsed.data.contributes?.selection_actions).toEqual([action]);
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
|
|
808
877
|
describe("mcp_servers OAuth", () => {
|
|
809
878
|
const manifest = {
|
|
810
879
|
id: "com.example.mail",
|
package/src/manifest.ts
CHANGED
|
@@ -346,28 +346,47 @@ export type WidgetContribution = z.infer<typeof WidgetContributionSchema>;
|
|
|
346
346
|
|
|
347
347
|
/** Metadata-only chat affordance. The host owns rendering and dispatch; the
|
|
348
348
|
* manifest carries identifiers and copy only. */
|
|
349
|
-
export const ChatWidgetTemplateSchema = z
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
.object({
|
|
357
|
-
tool_id: z
|
|
358
|
-
|
|
349
|
+
export const ChatWidgetTemplateSchema = z
|
|
350
|
+
.object({
|
|
351
|
+
id: z.string().regex(/^[a-z0-9][a-z0-9._:-]*$/),
|
|
352
|
+
title: z.string().min(1),
|
|
353
|
+
description: z.string().optional(),
|
|
354
|
+
triggers: z.array(z.string()).default([]),
|
|
355
|
+
examples: z.array(z.string()).default([]),
|
|
356
|
+
backing: z.object({
|
|
357
|
+
tool_id: z
|
|
358
|
+
.string()
|
|
359
|
+
.regex(/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/)
|
|
360
|
+
.optional(),
|
|
361
|
+
view_id: z
|
|
362
|
+
.string()
|
|
363
|
+
.regex(/^[a-z0-9][a-z0-9._:-]*$/)
|
|
364
|
+
.optional(),
|
|
359
365
|
}),
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
366
|
+
display_mode: z.string().min(1),
|
|
367
|
+
safe_action_ids: z
|
|
368
|
+
.array(z.string().regex(/^[a-z0-9][a-z0-9._-]*$/))
|
|
369
|
+
.default([]),
|
|
370
|
+
availability: z.string().default("available"),
|
|
363
371
|
})
|
|
364
372
|
.superRefine((value, ctx) => {
|
|
365
|
-
const count =
|
|
373
|
+
const count =
|
|
374
|
+
Number(Boolean(value.backing.tool_id)) +
|
|
375
|
+
Number(Boolean(value.backing.view_id));
|
|
366
376
|
if (count !== 1 && value.availability === "available") {
|
|
367
|
-
ctx.addIssue({
|
|
377
|
+
ctx.addIssue({
|
|
378
|
+
code: "custom",
|
|
379
|
+
path: ["backing"],
|
|
380
|
+
message:
|
|
381
|
+
"available templates need exactly one backing tool_id or view_id",
|
|
382
|
+
});
|
|
368
383
|
}
|
|
369
384
|
if (count > 1) {
|
|
370
|
-
ctx.addIssue({
|
|
385
|
+
ctx.addIssue({
|
|
386
|
+
code: "custom",
|
|
387
|
+
path: ["backing"],
|
|
388
|
+
message: "backing must declare at most one of tool_id or view_id",
|
|
389
|
+
});
|
|
371
390
|
}
|
|
372
391
|
});
|
|
373
392
|
|
|
@@ -406,10 +425,70 @@ export const ToolAppConfigSchema = z.object({
|
|
|
406
425
|
|
|
407
426
|
export type ToolAppConfig = z.infer<typeof ToolAppConfigSchema>;
|
|
408
427
|
|
|
428
|
+
/** One selectable value for a plugin/app slash-command argument. */
|
|
429
|
+
export const SlashCommandOptionSchema = z
|
|
430
|
+
.object({
|
|
431
|
+
description: z.string().optional(),
|
|
432
|
+
label: z.string().min(1),
|
|
433
|
+
value: z.string().min(1),
|
|
434
|
+
})
|
|
435
|
+
.passthrough();
|
|
436
|
+
|
|
437
|
+
export type SlashCommandOption = z.infer<typeof SlashCommandOptionSchema>;
|
|
438
|
+
|
|
439
|
+
/** A registered free-form option shown alongside an argument's choices. */
|
|
440
|
+
export const SlashCommandCustomOptionSchema = z.union([
|
|
441
|
+
z.literal(true),
|
|
442
|
+
z
|
|
443
|
+
.object({
|
|
444
|
+
description: z.string().optional(),
|
|
445
|
+
label: z.string().min(1).optional(),
|
|
446
|
+
})
|
|
447
|
+
.passthrough(),
|
|
448
|
+
]);
|
|
449
|
+
|
|
450
|
+
export type SlashCommandCustomOption = z.infer<
|
|
451
|
+
typeof SlashCommandCustomOptionSchema
|
|
452
|
+
>;
|
|
453
|
+
|
|
454
|
+
/** One sequential argument in a plugin/app slash command. */
|
|
455
|
+
export const SlashCommandArgumentSchema = z
|
|
456
|
+
.object({
|
|
457
|
+
allow_custom: z.boolean().optional(),
|
|
458
|
+
custom: SlashCommandCustomOptionSchema.optional(),
|
|
459
|
+
description: z.string().optional(),
|
|
460
|
+
name: z.string().min(1),
|
|
461
|
+
options: z.array(SlashCommandOptionSchema).optional(),
|
|
462
|
+
})
|
|
463
|
+
.passthrough();
|
|
464
|
+
|
|
465
|
+
export type SlashCommandArgument = z.infer<typeof SlashCommandArgumentSchema>;
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* A command registered by a plugin or Ryu App. `args` is the preferred key;
|
|
469
|
+
* `parameters` is accepted as a readable alias for hand-authored manifests.
|
|
470
|
+
* Each argument's options are plugin-owned, so the shell never needs a closed
|
|
471
|
+
* enum for app-specific values.
|
|
472
|
+
*/
|
|
473
|
+
export const SlashCommandContributionSchema = z
|
|
474
|
+
.object({
|
|
475
|
+
args: z.array(SlashCommandArgumentSchema).optional(),
|
|
476
|
+
body: z.string().optional(),
|
|
477
|
+
command: z.string().min(1),
|
|
478
|
+
description: z.string().optional(),
|
|
479
|
+
id: z.string().optional(),
|
|
480
|
+
parameters: z.array(SlashCommandArgumentSchema).optional(),
|
|
481
|
+
})
|
|
482
|
+
.passthrough();
|
|
483
|
+
|
|
484
|
+
export type SlashCommandContribution = z.infer<
|
|
485
|
+
typeof SlashCommandContributionSchema
|
|
486
|
+
>;
|
|
487
|
+
|
|
409
488
|
/**
|
|
410
489
|
* The `contributes` block. Mirrors `Contributes` in
|
|
411
490
|
* `apps/core/src/plugin_manifest/mod.rs`. The declarative UI surfaces
|
|
412
|
-
* (`composer_controls` / `chat_features` / `settings_tabs`
|
|
491
|
+
* (`composer_controls` / `chat_features` / `settings_tabs`) are passed verbatim
|
|
413
492
|
* to the desktop renderer, so they are typed loosely here (records).
|
|
414
493
|
*/
|
|
415
494
|
export const ContributesSchema = z.object({
|
|
@@ -426,7 +505,7 @@ export const ContributesSchema = z.object({
|
|
|
426
505
|
* feature declaration before signing. */
|
|
427
506
|
chat_features: z.array(z.record(z.string(), z.unknown())).default([]),
|
|
428
507
|
settings_tabs: z.array(z.record(z.string(), z.unknown())).default([]),
|
|
429
|
-
slash_commands: z.array(
|
|
508
|
+
slash_commands: z.array(SlashCommandContributionSchema).default([]),
|
|
430
509
|
/** App widgets (Ryu Apps). Each binds a render tool id to its
|
|
431
510
|
* `ui://widget/<slug>.html` template. Mirrors the Rust-side
|
|
432
511
|
* `Contributes.widgets` field, without which the CLI's zod parse would strip
|
|
@@ -496,6 +575,9 @@ export const ContributesSchema = z.object({
|
|
|
496
575
|
/** Per-message actions contributed by an enabled plugin. Kept as loose records
|
|
497
576
|
* so renderer-specific `kind`/`args` payloads survive `ryu pack` unchanged. */
|
|
498
577
|
message_actions: z.array(z.record(z.string(), z.unknown())).default([]),
|
|
578
|
+
/** Buttons contributed to the floating text-selection toolbar. Kept as loose
|
|
579
|
+
* records so host-owned dispatch args survive `ryu pack` unchanged. */
|
|
580
|
+
selection_actions: z.array(z.record(z.string(), z.unknown())).default([]),
|
|
499
581
|
});
|
|
500
582
|
|
|
501
583
|
export type Contributes = z.infer<typeof ContributesSchema>;
|
|
@@ -765,6 +847,10 @@ export const PluginManifestSchema = z
|
|
|
765
847
|
"version must be a valid semver string (e.g. 1.0.0)"
|
|
766
848
|
),
|
|
767
849
|
|
|
850
|
+
/** Core-owned release maturity metadata. The Rust contract validates the
|
|
851
|
+
* richer shape; the SDK authoring parser must preserve it for pack/publish. */
|
|
852
|
+
stability: z.unknown().optional(),
|
|
853
|
+
|
|
768
854
|
/**
|
|
769
855
|
* Lower-case hex `sha256(utf8_bytes(ui_code))` binding the plugin's bundled
|
|
770
856
|
* sandboxed-UI code to this manifest. `ryu pack` / `ryu publish` compute it and
|
|
@@ -785,9 +871,34 @@ export const PluginManifestSchema = z
|
|
|
785
871
|
*/
|
|
786
872
|
permission_grants: z.array(z.string()).default([]),
|
|
787
873
|
|
|
874
|
+
/** Deny-by-default sandbox permissions. Core remains authoritative; this
|
|
875
|
+
* schema mirrors the fields so `ryu pack` cannot strip them. */
|
|
876
|
+
permissions: z
|
|
877
|
+
.object({
|
|
878
|
+
fs: z
|
|
879
|
+
.object({
|
|
880
|
+
read: z.array(z.string()).default([]),
|
|
881
|
+
write: z.array(z.string()).default([]),
|
|
882
|
+
})
|
|
883
|
+
.optional(),
|
|
884
|
+
child_process: z.boolean().optional(),
|
|
885
|
+
run: z.array(z.string()).default([]),
|
|
886
|
+
network: z.union([z.boolean(), z.array(z.string())]).optional(),
|
|
887
|
+
tool: z.array(z.string()).default([]),
|
|
888
|
+
})
|
|
889
|
+
.optional(),
|
|
890
|
+
|
|
891
|
+
/** Core-owned permission presentation levels. Preserved verbatim here and
|
|
892
|
+
* validated by Core's authoritative manifest contract. */
|
|
893
|
+
permission_levels: z.unknown().optional(),
|
|
894
|
+
|
|
788
895
|
/** Remote or stdio MCP servers registered by this plugin. */
|
|
789
896
|
mcp_servers: z.record(z.string(), McpServerDeclSchema).optional(),
|
|
790
897
|
|
|
898
|
+
/** Core-owned sidecar declarations. Their full process/HTTP schema stays in
|
|
899
|
+
* the Rust contract; this authoring layer must never strip them. */
|
|
900
|
+
sidecars: z.unknown().optional(),
|
|
901
|
+
|
|
791
902
|
/**
|
|
792
903
|
* Optional Companion surface (an in-desktop overlay or sidebar panel).
|
|
793
904
|
* Absent when the plugin has no Companion surface.
|
|
@@ -828,6 +939,10 @@ export const PluginManifestSchema = z
|
|
|
828
939
|
*/
|
|
829
940
|
targets: z.array(SurfaceSchema).default([]),
|
|
830
941
|
|
|
942
|
+
/** Rich per-surface declarations (`support`, UI, contributed commands, …).
|
|
943
|
+
* Core owns and validates the nested vocabulary. */
|
|
944
|
+
surfaces: z.unknown().optional(),
|
|
945
|
+
|
|
831
946
|
/**
|
|
832
947
|
* Host version floors — the semver requirement each surface must satisfy for
|
|
833
948
|
* this plugin to install. Mirrors Core's `EnginesReq`
|
|
@@ -954,6 +1069,10 @@ export const PluginManifestSchema = z
|
|
|
954
1069
|
*/
|
|
955
1070
|
setup: z.union([SetupStepSchema, z.array(SetupStepSchema)]).optional(),
|
|
956
1071
|
})
|
|
1072
|
+
// Core's Rust-derived schema is the full wire authority. Keep this deliberately
|
|
1073
|
+
// forward-compatible so a newly-added Core field survives SDK pack/publish even
|
|
1074
|
+
// before the simpler authoring schema grows first-class validation for it.
|
|
1075
|
+
.passthrough()
|
|
957
1076
|
.superRefine((manifest, context) => {
|
|
958
1077
|
const hasOAuthServer = Object.values(manifest.mcp_servers ?? {}).some(
|
|
959
1078
|
(server) => server.auth?.type === "oauth"
|
package/src/mcp/server.ts
CHANGED
|
@@ -28,7 +28,8 @@ import { callTool, listTools, MCP_PROTOCOL_VERSION } from "./client.ts";
|
|
|
28
28
|
/** JSON Schema fragment — enough to describe a tool's input arguments. */
|
|
29
29
|
export interface JsonSchema {
|
|
30
30
|
description?: string;
|
|
31
|
-
|
|
31
|
+
/** Property schemas may use any JSON Schema dialect or nested shape. */
|
|
32
|
+
properties?: Record<string, unknown>;
|
|
32
33
|
required?: string[];
|
|
33
34
|
type?: string;
|
|
34
35
|
[key: string]: unknown;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { PluginManifestSchema } from "../manifest.ts";
|
|
3
|
+
import { defineAction } from "./action.ts";
|
|
4
|
+
import type { GatewayClient, RunnableContext } from "./runnable-types.ts";
|
|
5
|
+
|
|
6
|
+
const gateway: GatewayClient = {
|
|
7
|
+
chat: async () => ({ content: "ok", finishReason: "stop" }),
|
|
8
|
+
async *stream() {
|
|
9
|
+
yield { content: "ok", finishReason: null };
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const context: RunnableContext = { gateway };
|
|
14
|
+
|
|
15
|
+
const ticketAction = defineAction({
|
|
16
|
+
id: "action-create-ticket",
|
|
17
|
+
name: "Create Ticket",
|
|
18
|
+
description: "Create a support ticket for a customer.",
|
|
19
|
+
schema: {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
customer: { type: "string" },
|
|
23
|
+
summary: { type: "string" },
|
|
24
|
+
},
|
|
25
|
+
required: ["customer", "summary"],
|
|
26
|
+
},
|
|
27
|
+
outputSchema: {
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: {
|
|
30
|
+
id: { type: "string" },
|
|
31
|
+
},
|
|
32
|
+
required: ["id"],
|
|
33
|
+
},
|
|
34
|
+
effect: "mutate",
|
|
35
|
+
needsApproval: true,
|
|
36
|
+
run: async ({ customer, summary }) => ({
|
|
37
|
+
id: `${customer}:${summary}`,
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("defineAction", () => {
|
|
42
|
+
it("returns a governed action with the same Runnable execution contract", async () => {
|
|
43
|
+
expect(ticketAction.kind).toBe("tool");
|
|
44
|
+
expect(ticketAction.action).toBe(true);
|
|
45
|
+
expect(ticketAction.description).toBe(
|
|
46
|
+
"Create a support ticket for a customer."
|
|
47
|
+
);
|
|
48
|
+
expect(ticketAction.schema.required).toEqual(["customer", "summary"]);
|
|
49
|
+
expect(ticketAction.outputSchema?.required).toEqual(["id"]);
|
|
50
|
+
expect(ticketAction.annotations).toEqual({
|
|
51
|
+
destructiveHint: true,
|
|
52
|
+
readOnlyHint: false,
|
|
53
|
+
});
|
|
54
|
+
expect(ticketAction.needsApproval).toBe(true);
|
|
55
|
+
expect(
|
|
56
|
+
await ticketAction.run({ customer: "acme", summary: "Login" }, context)
|
|
57
|
+
).toEqual({ id: "acme:Login" });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("rejects an action whose declared effect conflicts with its annotations", () => {
|
|
61
|
+
expect(() =>
|
|
62
|
+
defineAction({
|
|
63
|
+
id: "action-conflict",
|
|
64
|
+
name: "Conflicting Action",
|
|
65
|
+
description: "An invalid action.",
|
|
66
|
+
schema: { type: "object", properties: {} },
|
|
67
|
+
effect: "read",
|
|
68
|
+
annotations: { destructiveHint: true },
|
|
69
|
+
run: async () => null,
|
|
70
|
+
})
|
|
71
|
+
).toThrow(/destructiveHint/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("lowers to a Core-compatible governed inline tool manifest", () => {
|
|
75
|
+
const manifest = ticketAction.toManifest({
|
|
76
|
+
id: "com.example.support",
|
|
77
|
+
version: "1.0.0",
|
|
78
|
+
grants: ["storage:kv"],
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(() => PluginManifestSchema.parse(manifest)).not.toThrow();
|
|
82
|
+
expect(manifest.permission_grants).toEqual(["storage:kv", "tool:execute"]);
|
|
83
|
+
expect(manifest.runnables).toHaveLength(1);
|
|
84
|
+
expect(manifest.runnables[0]).toMatchObject({
|
|
85
|
+
id: "action-create-ticket",
|
|
86
|
+
name: "Create Ticket",
|
|
87
|
+
kind: "tool",
|
|
88
|
+
});
|
|
89
|
+
expect(manifest.runnables[0]?.config).toMatchObject({
|
|
90
|
+
slug: "action-create-ticket",
|
|
91
|
+
backend: "inline_deno",
|
|
92
|
+
action: true,
|
|
93
|
+
description: "Create a support ticket for a customer.",
|
|
94
|
+
input_schema: ticketAction.schema,
|
|
95
|
+
output_schema: ticketAction.outputSchema,
|
|
96
|
+
annotations: ticketAction.annotations,
|
|
97
|
+
needs_approval: true,
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("adapts the same implementation to the SDK MCP server", async () => {
|
|
102
|
+
const mcpTool = ticketAction.toMcpTool(context);
|
|
103
|
+
|
|
104
|
+
expect(mcpTool.name).toBe("action-create-ticket");
|
|
105
|
+
expect(mcpTool.description).toBe("Create a support ticket for a customer.");
|
|
106
|
+
expect(mcpTool.inputSchema).toEqual(ticketAction.schema);
|
|
107
|
+
expect(await mcpTool.run({ customer: "acme", summary: "MCP" })).toEqual({
|
|
108
|
+
id: "acme:MCP",
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("allows explicit read-only actions without approval", () => {
|
|
113
|
+
const action = defineAction({
|
|
114
|
+
id: "action-find-ticket",
|
|
115
|
+
name: "Find Ticket",
|
|
116
|
+
description: "Find a ticket by id.",
|
|
117
|
+
schema: { type: "object", properties: { id: { type: "string" } } },
|
|
118
|
+
effect: "read",
|
|
119
|
+
run: async ({ id }) => ({ id }),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(action.annotations).toEqual({
|
|
123
|
+
readOnlyHint: true,
|
|
124
|
+
destructiveHint: false,
|
|
125
|
+
});
|
|
126
|
+
expect(action.needsApproval).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical Ryu Action authoring API.
|
|
3
|
+
*
|
|
4
|
+
* An Action is the semantic contract for a business operation: one description,
|
|
5
|
+
* input schema, output schema, implementation, and effect declaration. It lowers
|
|
6
|
+
* to Ryu's existing governed `inline_deno` Tool backend, so this adds a coherent
|
|
7
|
+
* authoring seam without introducing a second Core execution runtime.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
type PluginManifest,
|
|
12
|
+
PluginManifestSchema,
|
|
13
|
+
type RunnableMeta,
|
|
14
|
+
type Surface,
|
|
15
|
+
} from "../manifest.ts";
|
|
16
|
+
import type { SdkRunnable } from "../mcp/server.ts";
|
|
17
|
+
import type { RunnableContext } from "./runnable-types.ts";
|
|
18
|
+
import {
|
|
19
|
+
defineTool,
|
|
20
|
+
type InlineToolManifestOptions,
|
|
21
|
+
inlineToolRunnable,
|
|
22
|
+
type ToolRunnable,
|
|
23
|
+
type ToolSchema,
|
|
24
|
+
} from "./tool.ts";
|
|
25
|
+
|
|
26
|
+
/** The two effects Core can enforce from tool annotations. */
|
|
27
|
+
export type ActionEffect = "mutate" | "read";
|
|
28
|
+
|
|
29
|
+
/** MCP-compatible effect hints plus any future provider-neutral boolean hints. */
|
|
30
|
+
export interface ActionAnnotations {
|
|
31
|
+
destructiveHint?: boolean;
|
|
32
|
+
idempotentHint?: boolean;
|
|
33
|
+
openWorldHint?: boolean;
|
|
34
|
+
readOnlyHint?: boolean;
|
|
35
|
+
[key: string]: boolean | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Options for defining a canonical Action. */
|
|
39
|
+
export interface ActionOptions<
|
|
40
|
+
TInput extends Record<string, unknown>,
|
|
41
|
+
TOutput,
|
|
42
|
+
> {
|
|
43
|
+
/** Additional MCP-compatible effect hints. */
|
|
44
|
+
annotations?: ActionAnnotations;
|
|
45
|
+
/** Description the agent reads when deciding whether to call the action. */
|
|
46
|
+
description: string;
|
|
47
|
+
/** Explicit effect used by read-only and approval enforcement. */
|
|
48
|
+
effect: ActionEffect;
|
|
49
|
+
/** Stable action id, also used as the generated Core tool slug. */
|
|
50
|
+
id: string;
|
|
51
|
+
/** Human-readable action name. */
|
|
52
|
+
name: string;
|
|
53
|
+
/** Require human approval even when global smart mode would not classify it. */
|
|
54
|
+
needsApproval?: boolean;
|
|
55
|
+
/** JSON Schema for the structured action result. */
|
|
56
|
+
outputSchema?: Record<string, unknown>;
|
|
57
|
+
/** The one implementation used by local callers and the packaged tool body. */
|
|
58
|
+
run(input: TInput, ctx: RunnableContext): Promise<TOutput>;
|
|
59
|
+
/** JSON Schema for all action inputs. */
|
|
60
|
+
schema: ToolSchema;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Options for lowering one Action into a standalone Ryu plugin manifest. */
|
|
64
|
+
export interface ActionManifestOptions {
|
|
65
|
+
/** Plugin activation events; defaults to eager activation. */
|
|
66
|
+
activationEvents?: readonly string[];
|
|
67
|
+
/** Extra Gateway grants required by the action body. */
|
|
68
|
+
grants?: readonly string[];
|
|
69
|
+
/** Reverse-domain plugin id (for example `com.acme.support`). */
|
|
70
|
+
id: string;
|
|
71
|
+
/** Display name; defaults to the Action name. */
|
|
72
|
+
name?: string;
|
|
73
|
+
/** Host surfaces this plugin targets. */
|
|
74
|
+
targets?: readonly Surface[];
|
|
75
|
+
/** Plugin semver. */
|
|
76
|
+
version: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A ToolRunnable with Action semantics and manifest/MCP adapters. */
|
|
80
|
+
export interface ActionRunnable<
|
|
81
|
+
TInput extends Record<string, unknown> = Record<string, unknown>,
|
|
82
|
+
TOutput = unknown,
|
|
83
|
+
> extends ToolRunnable<TInput, TOutput> {
|
|
84
|
+
/** Discriminates this semantic contract from an ordinary ToolRunnable. */
|
|
85
|
+
readonly action: true;
|
|
86
|
+
/** Effect hints lowered into Core's existing tool metadata. */
|
|
87
|
+
readonly annotations: ActionAnnotations;
|
|
88
|
+
/** Required action description. */
|
|
89
|
+
readonly description: string;
|
|
90
|
+
/** Declared effect. */
|
|
91
|
+
readonly effect: ActionEffect;
|
|
92
|
+
/** Whether Core must queue approval before execution. */
|
|
93
|
+
readonly needsApproval: boolean;
|
|
94
|
+
/** Structured result schema, when the action returns one. */
|
|
95
|
+
readonly outputSchema?: Record<string, unknown>;
|
|
96
|
+
/** Lower this Action to a validated, installable plugin manifest. */
|
|
97
|
+
toManifest(options: ActionManifestOptions): PluginManifest;
|
|
98
|
+
/** Adapt this Action to the SDK MCP server using the same implementation. */
|
|
99
|
+
toMcpTool(context: RunnableContext): SdkRunnable;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function deriveAnnotations(
|
|
103
|
+
effect: ActionEffect,
|
|
104
|
+
annotations: ActionAnnotations | undefined
|
|
105
|
+
): ActionAnnotations {
|
|
106
|
+
const resolved = {
|
|
107
|
+
...annotations,
|
|
108
|
+
readOnlyHint: annotations?.readOnlyHint ?? effect === "read",
|
|
109
|
+
destructiveHint: annotations?.destructiveHint ?? effect === "mutate",
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
if (effect === "read" && resolved.destructiveHint) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
"[ryu-sdk] read actions cannot set annotations.destructiveHint=true"
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (effect === "mutate" && resolved.readOnlyHint) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
"[ryu-sdk] mutate actions cannot set annotations.readOnlyHint=true"
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return resolved;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function actionManifestEntry(action: ActionRunnable): RunnableMeta {
|
|
127
|
+
const options: InlineToolManifestOptions = {
|
|
128
|
+
action: true,
|
|
129
|
+
annotations: action.annotations,
|
|
130
|
+
description: action.description,
|
|
131
|
+
needsApproval: action.needsApproval,
|
|
132
|
+
...(action.outputSchema ? { outputSchema: action.outputSchema } : {}),
|
|
133
|
+
};
|
|
134
|
+
return inlineToolRunnable(action, options);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function actionToManifest(
|
|
138
|
+
action: ActionRunnable,
|
|
139
|
+
options: ActionManifestOptions
|
|
140
|
+
): PluginManifest {
|
|
141
|
+
const grants = [...new Set([...(options.grants ?? []), "tool:execute"])];
|
|
142
|
+
const raw = {
|
|
143
|
+
id: options.id,
|
|
144
|
+
name: options.name ?? action.name,
|
|
145
|
+
version: options.version,
|
|
146
|
+
runnables: [actionManifestEntry(action)],
|
|
147
|
+
permission_grants: grants,
|
|
148
|
+
activation_events: [...(options.activationEvents ?? ["*"])],
|
|
149
|
+
targets: [...(options.targets ?? [])],
|
|
150
|
+
};
|
|
151
|
+
const result = PluginManifestSchema.safeParse(raw);
|
|
152
|
+
if (!result.success) {
|
|
153
|
+
const first = result.error.issues[0];
|
|
154
|
+
const field = first?.path.join(".") ?? "unknown";
|
|
155
|
+
const message = first?.message ?? "validation failed";
|
|
156
|
+
throw new Error(
|
|
157
|
+
`[ryu-sdk] action manifest validation failed at '${field}': ${message}`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return result.data;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Define one business operation that can be run locally, packaged as a Core
|
|
165
|
+
* tool, or registered on an SDK MCP server without rewriting its implementation.
|
|
166
|
+
*/
|
|
167
|
+
export function defineAction<
|
|
168
|
+
TInput extends Record<string, unknown> = Record<string, unknown>,
|
|
169
|
+
TOutput = unknown,
|
|
170
|
+
>(options: ActionOptions<TInput, TOutput>): ActionRunnable<TInput, TOutput> {
|
|
171
|
+
const annotations = deriveAnnotations(options.effect, options.annotations);
|
|
172
|
+
const tool = defineTool({
|
|
173
|
+
description: options.description,
|
|
174
|
+
id: options.id,
|
|
175
|
+
name: options.name,
|
|
176
|
+
run: options.run,
|
|
177
|
+
schema: options.schema,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const action: ActionRunnable<TInput, TOutput> = {
|
|
181
|
+
...tool,
|
|
182
|
+
action: true,
|
|
183
|
+
annotations,
|
|
184
|
+
description: options.description,
|
|
185
|
+
effect: options.effect,
|
|
186
|
+
needsApproval: options.needsApproval ?? false,
|
|
187
|
+
...(options.outputSchema ? { outputSchema: options.outputSchema } : {}),
|
|
188
|
+
toManifest(manifestOptions) {
|
|
189
|
+
return actionToManifest(action, manifestOptions);
|
|
190
|
+
},
|
|
191
|
+
toMcpTool(context) {
|
|
192
|
+
return {
|
|
193
|
+
name: action.id,
|
|
194
|
+
description: action.description,
|
|
195
|
+
inputSchema: action.schema,
|
|
196
|
+
run: (input) => action.run(input as TInput, context),
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
return action;
|
|
202
|
+
}
|