@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,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Runnable interface and context types.
|
|
3
|
+
*
|
|
4
|
+
* Every factory (defineAgent, defineWorkflow, defineTool, defineSkill) returns
|
|
5
|
+
* a value that satisfies the `Runnable` interface. This keeps the contract
|
|
6
|
+
* in one place and avoids circular imports between the four factory modules.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ChatDelta, ChatMessage, ChatResult } from "../model/client.ts";
|
|
10
|
+
import type {
|
|
11
|
+
DurableClient,
|
|
12
|
+
EnginesClient,
|
|
13
|
+
ImageClient,
|
|
14
|
+
MemoryClient,
|
|
15
|
+
RagClient,
|
|
16
|
+
RealtimeClient,
|
|
17
|
+
SttClient,
|
|
18
|
+
TtsClient,
|
|
19
|
+
} from "./primitives.ts";
|
|
20
|
+
|
|
21
|
+
export type { ChatDelta, ChatMessage, ChatResult };
|
|
22
|
+
|
|
23
|
+
// ── GatewayClient ─────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Thin client over the Ryu gateway `POST /v1/chat/completions` endpoint.
|
|
27
|
+
*
|
|
28
|
+
* This is the ONLY way a Runnable may invoke a model. Injected via
|
|
29
|
+
* `RunnableContext.gateway` so every call is gateway-mandatory — matching the
|
|
30
|
+
* Core-vs-Gateway rule (the SDK decides what runs; the gateway decides what is
|
|
31
|
+
* allowed/measured/paid).
|
|
32
|
+
*
|
|
33
|
+
* Mirrors the interface specified in packages/sdk/README.md §2 and the
|
|
34
|
+
* `ModelClient` shape in `packages/sdk/src/model/client.ts`.
|
|
35
|
+
*/
|
|
36
|
+
export interface GatewayClient {
|
|
37
|
+
/** POST /v1/chat/completions (non-streaming). */
|
|
38
|
+
chat(messages: ChatMessage[]): Promise<ChatResult>;
|
|
39
|
+
/** POST /v1/chat/completions (streaming SSE). */
|
|
40
|
+
stream(messages: ChatMessage[]): AsyncGenerator<ChatDelta>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── RunnableContext ───────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Context injected into every `Runnable.run()` call.
|
|
47
|
+
*
|
|
48
|
+
* The gateway client is always present (fail-closed): a missing gateway throws
|
|
49
|
+
* at construction time via `defineModel`, never at run time.
|
|
50
|
+
*/
|
|
51
|
+
export interface RunnableContext {
|
|
52
|
+
/** Durable primitive: checkpoint · resume (`crates/ryu-durable`). */
|
|
53
|
+
durable?: DurableClient;
|
|
54
|
+
/** Engines primitive: complete · embed (`crates/ryu-engines`). */
|
|
55
|
+
engines?: EnginesClient;
|
|
56
|
+
/**
|
|
57
|
+
* Gateway client — the single allowed path for model calls.
|
|
58
|
+
* Never null; a Runnable that needs a model must use this.
|
|
59
|
+
*/
|
|
60
|
+
gateway: GatewayClient;
|
|
61
|
+
/** Image primitive: generate (`crates/ryu-image`). */
|
|
62
|
+
image?: ImageClient;
|
|
63
|
+
/** Memory primitive: recall · store (`crates/ryu-memory`). */
|
|
64
|
+
memory?: MemoryClient;
|
|
65
|
+
|
|
66
|
+
// ── Composable primitive clients (program §6b) ────────────────────────────
|
|
67
|
+
//
|
|
68
|
+
// Each is a typed, gateway-mandatory client over a decomposed capability
|
|
69
|
+
// crate, mounted here so a Runnable composes primitives the same way a
|
|
70
|
+
// developer does: `ctx.rag.retrieve()`, `ctx.memory.recall()`, … They are
|
|
71
|
+
// OPTIONAL — present only when the runner injects a `PrimitiveTransport`
|
|
72
|
+
// (e.g. a Core node it holds a token for). Back-compat: a `{ gateway }` ctx
|
|
73
|
+
// still satisfies this interface. Wire a full bundle with
|
|
74
|
+
// `createPrimitives(transport)` from `./primitives.ts`.
|
|
75
|
+
|
|
76
|
+
/** RAG primitive: retrieve · embed · rerank (`crates/ryu-rag`). */
|
|
77
|
+
rag?: RagClient;
|
|
78
|
+
/** Realtime primitive: broadcast · subscribe (`crates/ryu-realtime`). */
|
|
79
|
+
realtime?: RealtimeClient;
|
|
80
|
+
/** Optional session id for stateful runs (Core session). */
|
|
81
|
+
sessionId?: string;
|
|
82
|
+
/** Signal to abort a long-running run. */
|
|
83
|
+
signal?: AbortSignal;
|
|
84
|
+
/** STT primitive: transcribe (`crates/ryu-stt`). */
|
|
85
|
+
stt?: SttClient;
|
|
86
|
+
/** TTS primitive: speak (`crates/ryu-tts`). */
|
|
87
|
+
tts?: TtsClient;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Runnable ──────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The single contract for everything that can run in Ryu:
|
|
94
|
+
* Agent | Workflow | Tool | Skill.
|
|
95
|
+
*
|
|
96
|
+
* Typed over `TInput` (what the caller passes) and `TOutput` (what run()
|
|
97
|
+
* returns). The `kind` field narrows the discriminated union.
|
|
98
|
+
*/
|
|
99
|
+
export interface Runnable<TInput = unknown, TOutput = unknown> {
|
|
100
|
+
/** Stable unique identifier (e.g. "agent-researcher"). */
|
|
101
|
+
readonly id: string;
|
|
102
|
+
/** Kind tag — narrows the discriminated union. */
|
|
103
|
+
readonly kind: "agent" | "workflow" | "tool" | "skill";
|
|
104
|
+
/** Human-readable name. */
|
|
105
|
+
readonly name: string;
|
|
106
|
+
/**
|
|
107
|
+
* Execute this Runnable.
|
|
108
|
+
*
|
|
109
|
+
* Every model call MUST go through `ctx.gateway`. Direct provider imports
|
|
110
|
+
* are forbidden by the SDK's egress enforcement (assertAllowedEgressUrl).
|
|
111
|
+
*/
|
|
112
|
+
run(input: TInput, ctx: RunnableContext): Promise<TOutput>;
|
|
113
|
+
}
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runnable authoring API tests — covers all four acceptance criteria for #205:
|
|
3
|
+
*
|
|
4
|
+
* AC1: defineAgent/defineWorkflow/defineTool/defineSkill each return a Runnable
|
|
5
|
+
* (id, kind, inputSchema via ToolRunnable, run).
|
|
6
|
+
* AC2: A tool defined with a typed schema validates input at run() and exposes
|
|
7
|
+
* a JSON Schema compatible with Core's ToolInfo.schema shape.
|
|
8
|
+
* AC3: An agent can reference a workflow as a named tool and a workflow can
|
|
9
|
+
* reference an agent as a step; a test exercises one nested invocation
|
|
10
|
+
* end to end against a mock model client.
|
|
11
|
+
* AC4: All model calls inside any Runnable route through the unit-c gateway
|
|
12
|
+
* client (no direct provider import path), verified by a test.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, it } from "bun:test";
|
|
16
|
+
import { defineAgent } from "./agent.ts";
|
|
17
|
+
import type { GatewayClient, RunnableContext } from "./runnable-types.ts";
|
|
18
|
+
import { defineSkill } from "./skill.ts";
|
|
19
|
+
import { defineTool, inlineToolRunnable } from "./tool.ts";
|
|
20
|
+
import { defineWorkflow } from "./workflow.ts";
|
|
21
|
+
|
|
22
|
+
// ── Mock gateway ──────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build a mock GatewayClient that records every chat call and returns a
|
|
26
|
+
* controlled response. Used to assert AC4 (all model calls route through
|
|
27
|
+
* the gateway client, never a direct provider).
|
|
28
|
+
*/
|
|
29
|
+
function makeMockGateway(replyContent = "mock-reply"): {
|
|
30
|
+
gateway: GatewayClient;
|
|
31
|
+
calls: Array<{ messages: Array<{ role: string; content: string }> }>;
|
|
32
|
+
} {
|
|
33
|
+
const calls: Array<{ messages: Array<{ role: string; content: string }> }> =
|
|
34
|
+
[];
|
|
35
|
+
|
|
36
|
+
const gateway: GatewayClient = {
|
|
37
|
+
chat(messages) {
|
|
38
|
+
calls.push({ messages: [...messages] });
|
|
39
|
+
return Promise.resolve({ content: replyContent, finishReason: "stop" });
|
|
40
|
+
},
|
|
41
|
+
stream(messages) {
|
|
42
|
+
calls.push({ messages: [...messages] });
|
|
43
|
+
const delta = { content: replyContent, finishReason: null };
|
|
44
|
+
async function* iterate() {
|
|
45
|
+
yield delta;
|
|
46
|
+
}
|
|
47
|
+
return iterate();
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return { gateway, calls };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function makeCtx(gateway: GatewayClient): RunnableContext {
|
|
55
|
+
return { gateway };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── AC1: each factory returns a Runnable with id/kind/run ─────────────────────
|
|
59
|
+
|
|
60
|
+
describe("defineAgent", () => {
|
|
61
|
+
it("returns a Runnable with kind=agent", () => {
|
|
62
|
+
const agent = defineAgent({
|
|
63
|
+
id: "agent-test",
|
|
64
|
+
name: "Test Agent",
|
|
65
|
+
run(_input, _ctx) {
|
|
66
|
+
return Promise.resolve("done");
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(agent.id).toBe("agent-test");
|
|
71
|
+
expect(agent.name).toBe("Test Agent");
|
|
72
|
+
expect(agent.kind).toBe("agent");
|
|
73
|
+
expect(typeof agent.run).toBe("function");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("defineWorkflow", () => {
|
|
78
|
+
it("returns a Runnable with kind=workflow", () => {
|
|
79
|
+
const wf = defineWorkflow({
|
|
80
|
+
id: "workflow-test",
|
|
81
|
+
name: "Test Workflow",
|
|
82
|
+
run(_input, _ctx) {
|
|
83
|
+
return Promise.resolve("done");
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(wf.id).toBe("workflow-test");
|
|
88
|
+
expect(wf.name).toBe("Test Workflow");
|
|
89
|
+
expect(wf.kind).toBe("workflow");
|
|
90
|
+
expect(typeof wf.run).toBe("function");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("defineTool", () => {
|
|
95
|
+
it("returns a ToolRunnable with kind=tool and a schema property", () => {
|
|
96
|
+
const tool = defineTool({
|
|
97
|
+
id: "tool-test",
|
|
98
|
+
name: "Test Tool",
|
|
99
|
+
schema: {
|
|
100
|
+
type: "object",
|
|
101
|
+
properties: { query: { type: "string" } },
|
|
102
|
+
required: ["query"],
|
|
103
|
+
},
|
|
104
|
+
run(input, _ctx) {
|
|
105
|
+
return Promise.resolve({ result: input.query });
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(tool.id).toBe("tool-test");
|
|
110
|
+
expect(tool.name).toBe("Test Tool");
|
|
111
|
+
expect(tool.kind).toBe("tool");
|
|
112
|
+
expect(typeof tool.run).toBe("function");
|
|
113
|
+
// ToolRunnable exposes schema
|
|
114
|
+
expect(tool.schema.type).toBe("object");
|
|
115
|
+
expect(tool.schema.properties.query?.type).toBe("string");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("serializes the run body for Core's inline_deno backend (like defineTurnHook)", () => {
|
|
119
|
+
const tool = defineTool({
|
|
120
|
+
id: "weather",
|
|
121
|
+
name: "Weather",
|
|
122
|
+
schema: { type: "object", properties: {}, required: [] },
|
|
123
|
+
run(input, _ctx) {
|
|
124
|
+
return Promise.resolve({ echoed: input });
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
// The serialized code invokes the body with the sandbox globals (input+host),
|
|
128
|
+
// matching the turn-hook serialization approach.
|
|
129
|
+
expect(tool.code.startsWith("return await (")).toBe(true);
|
|
130
|
+
expect(tool.code).toContain("(input, host)");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("inlineToolRunnable produces a shippable inline_deno tool config", () => {
|
|
134
|
+
const tool = defineTool({
|
|
135
|
+
id: "weather",
|
|
136
|
+
name: "Weather",
|
|
137
|
+
schema: { type: "object", properties: {}, required: [] },
|
|
138
|
+
run: (input) => Promise.resolve(input),
|
|
139
|
+
});
|
|
140
|
+
const meta = inlineToolRunnable(tool, { description: "Look up weather" });
|
|
141
|
+
expect(meta.kind).toBe("tool");
|
|
142
|
+
expect(meta.id).toBe("weather");
|
|
143
|
+
expect(meta.config?.slug).toBe("weather");
|
|
144
|
+
expect(meta.config?.backend).toBe("inline_deno");
|
|
145
|
+
expect(meta.config?.description).toBe("Look up weather");
|
|
146
|
+
expect(typeof meta.config?.code).toBe("string");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("defineSkill", () => {
|
|
151
|
+
it("returns a Runnable with kind=skill", () => {
|
|
152
|
+
const skill = defineSkill({
|
|
153
|
+
id: "skill-test",
|
|
154
|
+
name: "Test Skill",
|
|
155
|
+
run(_input, _ctx) {
|
|
156
|
+
return Promise.resolve("done");
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(skill.id).toBe("skill-test");
|
|
161
|
+
expect(skill.name).toBe("Test Skill");
|
|
162
|
+
expect(skill.kind).toBe("skill");
|
|
163
|
+
expect(typeof skill.run).toBe("function");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ── AC2: tool schema validates input and exposes JSON Schema ──────────────────
|
|
168
|
+
|
|
169
|
+
describe("defineTool — input validation", () => {
|
|
170
|
+
const searchTool = defineTool({
|
|
171
|
+
id: "tool-search",
|
|
172
|
+
name: "Search",
|
|
173
|
+
schema: {
|
|
174
|
+
type: "object",
|
|
175
|
+
properties: {
|
|
176
|
+
query: { type: "string", description: "Search query" },
|
|
177
|
+
limit: { type: "integer", description: "Max results" },
|
|
178
|
+
},
|
|
179
|
+
required: ["query"],
|
|
180
|
+
},
|
|
181
|
+
run(input, _ctx) {
|
|
182
|
+
return Promise.resolve({ results: [`Result for: ${input.query}`] });
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("exposes JSON Schema compatible with Core ToolInfo.schema", () => {
|
|
187
|
+
// Core's ToolInfo.schema is serde_json::Value — the object shape must match
|
|
188
|
+
// apps/core/src/sidecar/adapters/mod.rs:66-71
|
|
189
|
+
const schema = searchTool.schema;
|
|
190
|
+
expect(schema.type).toBe("object");
|
|
191
|
+
expect(schema.properties).toBeDefined();
|
|
192
|
+
expect(schema.properties.query).toBeDefined();
|
|
193
|
+
expect(schema.properties.query?.type).toBe("string");
|
|
194
|
+
expect(schema.required).toContain("query");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("runs successfully when required fields are present", async () => {
|
|
198
|
+
const { gateway } = makeMockGateway();
|
|
199
|
+
const result = await searchTool.run({ query: "hello" }, makeCtx(gateway));
|
|
200
|
+
expect(result.results[0]).toBe("Result for: hello");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("throws when a required field is missing", async () => {
|
|
204
|
+
const { gateway } = makeMockGateway();
|
|
205
|
+
let caught: unknown;
|
|
206
|
+
try {
|
|
207
|
+
await searchTool.run({}, makeCtx(gateway));
|
|
208
|
+
} catch (err) {
|
|
209
|
+
caught = err;
|
|
210
|
+
}
|
|
211
|
+
expect(caught).toBeInstanceOf(Error);
|
|
212
|
+
expect((caught as Error).message).toContain(
|
|
213
|
+
'missing required field "query"'
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("throws when a field has the wrong type", async () => {
|
|
218
|
+
const { gateway } = makeMockGateway();
|
|
219
|
+
let caught: unknown;
|
|
220
|
+
try {
|
|
221
|
+
await searchTool.run({ query: 42 }, makeCtx(gateway));
|
|
222
|
+
} catch (err) {
|
|
223
|
+
caught = err;
|
|
224
|
+
}
|
|
225
|
+
expect(caught).toBeInstanceOf(Error);
|
|
226
|
+
expect((caught as Error).message).toContain('expected type "string"');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("throws when input is not an object", async () => {
|
|
230
|
+
const { gateway } = makeMockGateway();
|
|
231
|
+
let caught: unknown;
|
|
232
|
+
try {
|
|
233
|
+
// @ts-expect-error — intentional: testing runtime validation
|
|
234
|
+
await searchTool.run("bad", makeCtx(gateway));
|
|
235
|
+
} catch (err) {
|
|
236
|
+
caught = err;
|
|
237
|
+
}
|
|
238
|
+
expect(caught).toBeInstanceOf(Error);
|
|
239
|
+
expect((caught as Error).message).toContain("input must be an object");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ── AC3: nested invocation — agent invokes workflow, workflow invokes agent ───
|
|
244
|
+
|
|
245
|
+
describe("nested invocation (agent <-> workflow peer relationship)", () => {
|
|
246
|
+
it("workflow uses an agent as a step and returns combined output", async () => {
|
|
247
|
+
const { gateway, calls } = makeMockGateway("research-result");
|
|
248
|
+
const ctx = makeCtx(gateway);
|
|
249
|
+
|
|
250
|
+
// Inner agent: does research via the gateway
|
|
251
|
+
const researchAgent = defineAgent<{ query: string }, { answer: string }>({
|
|
252
|
+
id: "agent-research",
|
|
253
|
+
name: "Research Agent",
|
|
254
|
+
async run({ query }, innerCtx) {
|
|
255
|
+
const result = await innerCtx.gateway.chat([
|
|
256
|
+
{ role: "user", content: query },
|
|
257
|
+
]);
|
|
258
|
+
return { answer: result.content };
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Outer workflow: calls the agent as a step
|
|
263
|
+
const reportWorkflow = defineWorkflow<
|
|
264
|
+
{ topic: string },
|
|
265
|
+
{ report: string }
|
|
266
|
+
>({
|
|
267
|
+
id: "workflow-report",
|
|
268
|
+
name: "Report Workflow",
|
|
269
|
+
steps: [researchAgent],
|
|
270
|
+
async run({ topic }, wfCtx) {
|
|
271
|
+
const { answer } = await researchAgent.run({ query: topic }, wfCtx);
|
|
272
|
+
return { report: `Report on "${topic}": ${answer}` };
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const result = await reportWorkflow.run({ topic: "TypeScript" }, ctx);
|
|
277
|
+
|
|
278
|
+
expect(result.report).toBe('Report on "TypeScript": research-result');
|
|
279
|
+
// Gateway was called exactly once (by the agent step)
|
|
280
|
+
expect(calls).toHaveLength(1);
|
|
281
|
+
expect(calls[0]?.messages[0]?.content).toBe("TypeScript");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("agent invokes a workflow as a named tool", async () => {
|
|
285
|
+
const { gateway, calls } = makeMockGateway("workflow-output");
|
|
286
|
+
const ctx = makeCtx(gateway);
|
|
287
|
+
|
|
288
|
+
// A workflow that the agent can delegate to
|
|
289
|
+
const summaryWorkflow = defineWorkflow<
|
|
290
|
+
{ text: string },
|
|
291
|
+
{ summary: string }
|
|
292
|
+
>({
|
|
293
|
+
id: "workflow-summary",
|
|
294
|
+
name: "Summary Workflow",
|
|
295
|
+
async run({ text }, wfCtx) {
|
|
296
|
+
const result = await wfCtx.gateway.chat([
|
|
297
|
+
{ role: "user", content: `Summarise: ${text}` },
|
|
298
|
+
]);
|
|
299
|
+
return { summary: result.content };
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Agent that calls the workflow as a tool
|
|
304
|
+
const orchestratorAgent = defineAgent<{ doc: string }, { output: string }>({
|
|
305
|
+
id: "agent-orchestrator",
|
|
306
|
+
name: "Orchestrator Agent",
|
|
307
|
+
tools: [summaryWorkflow],
|
|
308
|
+
async run({ doc }, agentCtx) {
|
|
309
|
+
// Agent invokes the workflow as a peer (by calling its run())
|
|
310
|
+
const { summary } = await summaryWorkflow.run({ text: doc }, agentCtx);
|
|
311
|
+
return { output: summary };
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const result = await orchestratorAgent.run({ doc: "long document" }, ctx);
|
|
316
|
+
|
|
317
|
+
expect(result.output).toBe("workflow-output");
|
|
318
|
+
// Gateway was called once inside the workflow
|
|
319
|
+
expect(calls).toHaveLength(1);
|
|
320
|
+
expect(calls[0]?.messages[0]?.content).toBe("Summarise: long document");
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// ── AC4: all model calls route through the gateway client ────────────────────
|
|
325
|
+
|
|
326
|
+
describe("gateway-mandatory routing (AC4)", () => {
|
|
327
|
+
it("agent routes all model calls through ctx.gateway", async () => {
|
|
328
|
+
const { gateway, calls } = makeMockGateway("hello");
|
|
329
|
+
const agent = defineAgent({
|
|
330
|
+
id: "agent-gw",
|
|
331
|
+
name: "GW Agent",
|
|
332
|
+
async run(_input, ctx) {
|
|
333
|
+
return await ctx.gateway.chat([{ role: "user", content: "ping" }]);
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
await agent.run({}, makeCtx(gateway));
|
|
338
|
+
|
|
339
|
+
expect(calls).toHaveLength(1);
|
|
340
|
+
expect(calls[0]?.messages[0]?.content).toBe("ping");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("workflow routes all model calls through ctx.gateway", async () => {
|
|
344
|
+
const { gateway, calls } = makeMockGateway("hello");
|
|
345
|
+
const wf = defineWorkflow({
|
|
346
|
+
id: "wf-gw",
|
|
347
|
+
name: "GW Workflow",
|
|
348
|
+
async run(_input, ctx) {
|
|
349
|
+
return await ctx.gateway.chat([
|
|
350
|
+
{ role: "user", content: "workflow-ping" },
|
|
351
|
+
]);
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
await wf.run({}, makeCtx(gateway));
|
|
356
|
+
|
|
357
|
+
expect(calls).toHaveLength(1);
|
|
358
|
+
expect(calls[0]?.messages[0]?.content).toBe("workflow-ping");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("skill routes all model calls through ctx.gateway", async () => {
|
|
362
|
+
const { gateway, calls } = makeMockGateway("skill-reply");
|
|
363
|
+
const skill = defineSkill({
|
|
364
|
+
id: "skill-gw",
|
|
365
|
+
name: "GW Skill",
|
|
366
|
+
async run({ text }: { text: string }, ctx) {
|
|
367
|
+
return await ctx.gateway.chat([{ role: "user", content: text }]);
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
await skill.run({ text: "skill-ping" }, makeCtx(gateway));
|
|
372
|
+
|
|
373
|
+
expect(calls).toHaveLength(1);
|
|
374
|
+
expect(calls[0]?.messages[0]?.content).toBe("skill-ping");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("tool can optionally route model calls through ctx.gateway", async () => {
|
|
378
|
+
const { gateway, calls } = makeMockGateway("tool-model-reply");
|
|
379
|
+
const tool = defineTool({
|
|
380
|
+
id: "tool-gw",
|
|
381
|
+
name: "GW Tool",
|
|
382
|
+
schema: {
|
|
383
|
+
type: "object",
|
|
384
|
+
properties: { prompt: { type: "string" } },
|
|
385
|
+
required: ["prompt"],
|
|
386
|
+
},
|
|
387
|
+
async run({ prompt }: { prompt: string }, ctx) {
|
|
388
|
+
return await ctx.gateway.chat([{ role: "user", content: prompt }]);
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
await tool.run({ prompt: "tool-ping" }, makeCtx(gateway));
|
|
393
|
+
|
|
394
|
+
expect(calls).toHaveLength(1);
|
|
395
|
+
expect(calls[0]?.messages[0]?.content).toBe("tool-ping");
|
|
396
|
+
});
|
|
397
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* defineSkill — factory for Runnable skills.
|
|
3
|
+
*
|
|
4
|
+
* A skill is a prompt-template / capability block that is reusable across
|
|
5
|
+
* agents and workflows. Like a tool it is stateless, but its primary purpose
|
|
6
|
+
* is to encapsulate a reusable prompt pattern rather than a side-effectful
|
|
7
|
+
* function.
|
|
8
|
+
*
|
|
9
|
+
* All model calls must go through `ctx.gateway` — no direct provider imports.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Runnable, RunnableContext } from "./runnable-types.ts";
|
|
13
|
+
|
|
14
|
+
/** Options accepted by `defineSkill`. */
|
|
15
|
+
export interface SkillOptions<TInput, TOutput> {
|
|
16
|
+
/** Stable unique identifier (e.g. "skill-summarise"). */
|
|
17
|
+
id: string;
|
|
18
|
+
/** Human-readable display name. */
|
|
19
|
+
name: string;
|
|
20
|
+
/**
|
|
21
|
+
* The skill's run implementation.
|
|
22
|
+
*
|
|
23
|
+
* Skills typically build a prompt from `input` and call `ctx.gateway.chat()`
|
|
24
|
+
* to get a model response, then return structured output. All model calls
|
|
25
|
+
* MUST go through `ctx.gateway`.
|
|
26
|
+
*/
|
|
27
|
+
run(input: TInput, ctx: RunnableContext): Promise<TOutput>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create a Runnable skill.
|
|
32
|
+
*
|
|
33
|
+
* The returned value satisfies the `Runnable<TInput, TOutput>` interface with
|
|
34
|
+
* `kind = "skill"`.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* const summariseSkill = defineSkill({
|
|
39
|
+
* id: "skill-summarise",
|
|
40
|
+
* name: "Summarise",
|
|
41
|
+
* async run({ text }, ctx) {
|
|
42
|
+
* const result = await ctx.gateway.chat([
|
|
43
|
+
* { role: "user", content: `Summarise the following:\n\n${text}` },
|
|
44
|
+
* ]);
|
|
45
|
+
* return { summary: result.content };
|
|
46
|
+
* },
|
|
47
|
+
* });
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export function defineSkill<TInput = unknown, TOutput = unknown>(
|
|
51
|
+
options: SkillOptions<TInput, TOutput>
|
|
52
|
+
): Runnable<TInput, TOutput> {
|
|
53
|
+
const { id, name, run } = options;
|
|
54
|
+
return {
|
|
55
|
+
id,
|
|
56
|
+
name,
|
|
57
|
+
kind: "skill",
|
|
58
|
+
run,
|
|
59
|
+
} satisfies Runnable<TInput, TOutput>;
|
|
60
|
+
}
|