@nebutra/agent-runtime 0.2.1 → 2.0.0
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 +21 -676
- package/README.md +33 -10
- package/dist/adapters/index.d.ts +24 -5
- package/dist/adapters/index.js +27 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/{chunk-KCNN4QUQ.js → chunk-5UGIUYJR.js} +4 -4
- package/dist/chunk-7ELA4SWE.js +73 -0
- package/dist/chunk-7ELA4SWE.js.map +1 -0
- package/dist/chunk-Q62VKHIT.js +178 -0
- package/dist/chunk-Q62VKHIT.js.map +1 -0
- package/dist/chunk-VXILZAXK.js +541 -0
- package/dist/chunk-VXILZAXK.js.map +1 -0
- package/dist/command-exec.d.ts +45 -0
- package/dist/command-exec.js +15 -0
- package/dist/command-exec.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +81 -28
- package/dist/index.js.map +1 -1
- package/dist/orchestration.d.ts +42 -1
- package/dist/orchestration.js +7 -3
- package/dist/pulsar.js +2 -2
- package/dist/sandbox-DfcRttXt.d.ts +219 -0
- package/dist/sandbox.d.ts +2 -64
- package/dist/sandbox.js +25 -3
- package/package.json +81 -30
- package/.turbo/turbo-build.log +0 -130
- package/.turbo/turbo-test.log +0 -47
- package/.turbo/turbo-typecheck.log +0 -4
- package/CHANGELOG.md +0 -250
- package/dist/chunk-MUF7ZZTO.js +0 -57
- package/dist/chunk-MUF7ZZTO.js.map +0 -1
- package/dist/chunk-MX2WL43P.js +0 -90
- package/dist/chunk-MX2WL43P.js.map +0 -1
- package/examples/pulsar-quickstart.ts +0 -35
- package/examples/resume-branch.ts +0 -45
- package/examples/subagent-fanout.ts +0 -20
- package/src/adapters/dispatcher-sse.test.ts +0 -218
- package/src/adapters/dispatcher-sse.ts +0 -222
- package/src/adapters/index.ts +0 -18
- package/src/adapters/mcp-catalog.test.ts +0 -213
- package/src/adapters/mcp-catalog.ts +0 -188
- package/src/adapters/prisma-rollout.test.ts +0 -153
- package/src/adapters/prisma-rollout.ts +0 -104
- package/src/agent-runtime.test.ts +0 -176
- package/src/artifact-stream.test.ts +0 -330
- package/src/artifact-stream.ts +0 -453
- package/src/channel-gateway.test.ts +0 -432
- package/src/channel-gateway.ts +0 -357
- package/src/cli.ts +0 -49
- package/src/code-review.test.ts +0 -501
- package/src/code-review.ts +0 -495
- package/src/command-suggestions.test.ts +0 -251
- package/src/command-suggestions.ts +0 -338
- package/src/commands.test.ts +0 -184
- package/src/commands.ts +0 -140
- package/src/commit-message.test.ts +0 -249
- package/src/commit-message.ts +0 -180
- package/src/context-compaction.test.ts +0 -522
- package/src/context-compaction.ts +0 -438
- package/src/definitions.test.ts +0 -78
- package/src/definitions.ts +0 -190
- package/src/deployment-status.test.ts +0 -215
- package/src/deployment-status.ts +0 -227
- package/src/design-context.test.ts +0 -195
- package/src/design-context.ts +0 -198
- package/src/dispatcher.test.ts +0 -234
- package/src/dispatcher.ts +0 -189
- package/src/durable-turn.test.ts +0 -209
- package/src/durable-turn.ts +0 -135
- package/src/edit-planner.test.ts +0 -204
- package/src/edit-planner.ts +0 -325
- package/src/fuzzy-match.test.ts +0 -311
- package/src/fuzzy-match.ts +0 -444
- package/src/hook-pipeline.test.ts +0 -279
- package/src/hook-pipeline.ts +0 -373
- package/src/inbound-admission.test.ts +0 -394
- package/src/inbound-admission.ts +0 -246
- package/src/index.ts +0 -49
- package/src/loop.test.ts +0 -161
- package/src/loop.ts +0 -211
- package/src/mcp-bridge.test.ts +0 -165
- package/src/mcp-bridge.ts +0 -81
- package/src/memory-provider.test.ts +0 -232
- package/src/memory-provider.ts +0 -257
- package/src/model.ts +0 -168
- package/src/orchestration.test.ts +0 -53
- package/src/orchestration.ts +0 -146
- package/src/permission-ruleset.test.ts +0 -301
- package/src/permission-ruleset.ts +0 -1
- package/src/policy.ts +0 -151
- package/src/project-repo.test.ts +0 -232
- package/src/project-repo.ts +0 -311
- package/src/protocol.ts +0 -159
- package/src/pulsar.test.ts +0 -156
- package/src/pulsar.ts +0 -322
- package/src/rollout-store-persistent.test.ts +0 -217
- package/src/rollout-store-persistent.ts +0 -166
- package/src/rollout.ts +0 -150
- package/src/sandbox.ts +0 -113
- package/src/session-share.test.ts +0 -360
- package/src/session-share.ts +0 -310
- package/src/skill-distillation.test.ts +0 -177
- package/src/skill-distillation.ts +0 -369
- package/src/skills.test.ts +0 -277
- package/src/skills.ts +0 -277
- package/src/subagents.test.ts +0 -290
- package/src/subagents.ts +0 -332
- package/src/tools.test.ts +0 -12
- package/src/tools.ts +0 -129
- package/src/workbench.test.ts +0 -0
- package/src/workbench.ts +0 -0
- package/tsconfig.json +0 -12
- package/tsup.config.ts +0 -36
- /package/dist/{chunk-KCNN4QUQ.js.map → chunk-5UGIUYJR.js.map} +0 -0
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
import type { MCPContext, ToolExecutionResult } from "@nebutra/mcp";
|
|
2
|
-
import { MCPServerRegistry } from "@nebutra/mcp";
|
|
3
|
-
import { describe, expect, it, vi } from "vitest";
|
|
4
|
-
import type { z } from "zod";
|
|
5
|
-
import { activateMcpTools, type McpServerCatalogPort } from "../mcp-bridge";
|
|
6
|
-
import { ToolRegistry } from "../tools";
|
|
7
|
-
|
|
8
|
-
import { createMcpClientPort, createMcpServerCatalog } from "./mcp-catalog.js";
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Build a fresh, isolated registry seeded with two servers gated by plan and
|
|
12
|
-
* tenant. No network — `local` transport with in-process handlers.
|
|
13
|
-
*/
|
|
14
|
-
function seedRegistry(): MCPServerRegistry {
|
|
15
|
-
const registry = new MCPServerRegistry();
|
|
16
|
-
|
|
17
|
-
registry.register({
|
|
18
|
-
id: "weather",
|
|
19
|
-
name: "Weather",
|
|
20
|
-
description: "Weather tools",
|
|
21
|
-
endpoint: "local://weather",
|
|
22
|
-
transport: "local",
|
|
23
|
-
tools: [
|
|
24
|
-
{
|
|
25
|
-
name: "forecast",
|
|
26
|
-
description: "Get a forecast",
|
|
27
|
-
parameters: {
|
|
28
|
-
city: { type: "string", description: "City name", required: true },
|
|
29
|
-
days: { type: "number", description: "Day count" },
|
|
30
|
-
},
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
name: "alerts",
|
|
34
|
-
description: "Severe weather alerts (pro only)",
|
|
35
|
-
allowedPlans: ["pro", "enterprise"],
|
|
36
|
-
},
|
|
37
|
-
],
|
|
38
|
-
handlers: {
|
|
39
|
-
forecast: (args) => ({ ok: true, echo: args }),
|
|
40
|
-
alerts: () => ({ ok: true, alerts: [] }),
|
|
41
|
-
},
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
registry.register({
|
|
45
|
-
id: "acme-private",
|
|
46
|
-
name: "Acme Private",
|
|
47
|
-
description: "Tenant-scoped server",
|
|
48
|
-
endpoint: "local://acme",
|
|
49
|
-
transport: "local",
|
|
50
|
-
allowedTenants: ["tenant-acme"],
|
|
51
|
-
tools: [{ name: "acme_secret", description: "Acme-only tool" }],
|
|
52
|
-
handlers: { acme_secret: () => ({ ok: true }) },
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
return registry;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
describe("createMcpServerCatalog", () => {
|
|
59
|
-
it("fails closed on empty tenantId before touching the registry", async () => {
|
|
60
|
-
const registry = new MCPServerRegistry();
|
|
61
|
-
const spy = vi.spyOn(registry, "getAccessibleTools");
|
|
62
|
-
const catalog = createMcpServerCatalog(registry);
|
|
63
|
-
|
|
64
|
-
await expect(catalog.listTools({ tenantId: "" })).rejects.toThrow();
|
|
65
|
-
await expect(catalog.listTools({ tenantId: " " })).rejects.toThrow();
|
|
66
|
-
expect(spy).not.toHaveBeenCalled();
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("plan-gates: a free tenant sees fewer tools than a pro tenant", async () => {
|
|
70
|
-
const catalog = createMcpServerCatalog(seedRegistry());
|
|
71
|
-
|
|
72
|
-
const free = await catalog.listTools({ tenantId: "tenant-1", plan: "free" });
|
|
73
|
-
const pro = await catalog.listTools({ tenantId: "tenant-1", plan: "pro" });
|
|
74
|
-
|
|
75
|
-
const freeNames = free.map((e) => e.definition.name).sort();
|
|
76
|
-
const proNames = pro.map((e) => e.definition.name).sort();
|
|
77
|
-
|
|
78
|
-
expect(freeNames).toEqual(["forecast"]);
|
|
79
|
-
expect(proNames).toEqual(["alerts", "forecast"]);
|
|
80
|
-
expect(pro.length).toBeGreaterThan(free.length);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("isolates tenants: only the allowed tenant sees its private server", async () => {
|
|
84
|
-
const catalog = createMcpServerCatalog(seedRegistry());
|
|
85
|
-
|
|
86
|
-
const other = await catalog.listTools({ tenantId: "tenant-other", plan: "enterprise" });
|
|
87
|
-
const acme = await catalog.listTools({ tenantId: "tenant-acme", plan: "enterprise" });
|
|
88
|
-
|
|
89
|
-
expect(other.map((e) => e.definition.name)).not.toContain("acme_secret");
|
|
90
|
-
expect(acme.map((e) => e.definition.name)).toContain("acme_secret");
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it("produces a usable ToolDefinition even for a schema-less MCP tool", async () => {
|
|
94
|
-
const catalog = createMcpServerCatalog(seedRegistry());
|
|
95
|
-
const pro = await catalog.listTools({ tenantId: "tenant-1", plan: "pro" });
|
|
96
|
-
|
|
97
|
-
const alerts = pro.find((e) => e.definition.name === "alerts");
|
|
98
|
-
expect(alerts).toBeDefined();
|
|
99
|
-
// No `parameters` → permissive object schema, never throws on parse.
|
|
100
|
-
expect(() => alerts?.definition.inputSchema.parse({})).not.toThrow();
|
|
101
|
-
expect(() => alerts?.definition.inputSchema.parse({ anything: 1 })).not.toThrow();
|
|
102
|
-
expect(alerts?.server).toBe("weather");
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it("derives a Zod schema from MCP `parameters`", async () => {
|
|
106
|
-
const catalog = createMcpServerCatalog(seedRegistry());
|
|
107
|
-
const free = await catalog.listTools({ tenantId: "tenant-1", plan: "free" });
|
|
108
|
-
const forecast = free.find((e) => e.definition.name === "forecast");
|
|
109
|
-
|
|
110
|
-
expect(forecast).toBeDefined();
|
|
111
|
-
if (!forecast) throw new Error("forecast tool missing");
|
|
112
|
-
const schema = forecast.definition.inputSchema as z.ZodType;
|
|
113
|
-
// Required `city` enforced.
|
|
114
|
-
expect(() => schema.parse({})).toThrow();
|
|
115
|
-
expect(schema.parse({ city: "NYC" })).toMatchObject({ city: "NYC" });
|
|
116
|
-
expect(schema.parse({ city: "NYC", days: 3 })).toMatchObject({ days: 3 });
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
describe("createMcpClientPort", () => {
|
|
121
|
-
it("forwards tenantId into the MCPContext and unwraps the result envelope", async () => {
|
|
122
|
-
let seen: MCPContext | undefined;
|
|
123
|
-
const fake = {
|
|
124
|
-
async executeTool(
|
|
125
|
-
_name: string,
|
|
126
|
-
_args: Record<string, unknown>,
|
|
127
|
-
ctx: MCPContext,
|
|
128
|
-
): Promise<ToolExecutionResult> {
|
|
129
|
-
seen = ctx;
|
|
130
|
-
return { success: true, result: { value: 42 }, duration: 1 };
|
|
131
|
-
},
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
const port = createMcpClientPort(fake);
|
|
135
|
-
const out = await port.executeTool(
|
|
136
|
-
"forecast",
|
|
137
|
-
{ city: "NYC" },
|
|
138
|
-
{
|
|
139
|
-
requestId: "req-1",
|
|
140
|
-
tenantId: "tenant-xyz",
|
|
141
|
-
},
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
expect(out).toEqual({ value: 42 });
|
|
145
|
-
expect(seen?.tenantId).toBe("tenant-xyz");
|
|
146
|
-
expect(seen?.requestId).toBe("req-1");
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it("fails closed when the MCP client reports failure", async () => {
|
|
150
|
-
const fake = {
|
|
151
|
-
async executeTool(): Promise<ToolExecutionResult> {
|
|
152
|
-
return { success: false, error: "Access denied", duration: 1 };
|
|
153
|
-
},
|
|
154
|
-
};
|
|
155
|
-
const port = createMcpClientPort(fake);
|
|
156
|
-
|
|
157
|
-
await expect(port.executeTool("x", {}, { requestId: "r", tenantId: "t" })).rejects.toThrow(
|
|
158
|
-
/Access denied/,
|
|
159
|
-
);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it("rejects an empty tenantId at the client boundary (fail-closed)", async () => {
|
|
163
|
-
const fake = {
|
|
164
|
-
executeTool: vi.fn(
|
|
165
|
-
async (): Promise<ToolExecutionResult> => ({
|
|
166
|
-
success: true,
|
|
167
|
-
result: null,
|
|
168
|
-
duration: 0,
|
|
169
|
-
}),
|
|
170
|
-
),
|
|
171
|
-
};
|
|
172
|
-
const port = createMcpClientPort(fake);
|
|
173
|
-
|
|
174
|
-
await expect(port.executeTool("x", {}, { requestId: "r", tenantId: "" })).rejects.toThrow();
|
|
175
|
-
expect(fake.executeTool).not.toHaveBeenCalled();
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
describe("composition with agent-runtime activateMcpTools", () => {
|
|
180
|
-
it("registers catalog tools into a ToolRegistry and dispatches through the client port", async () => {
|
|
181
|
-
const catalog: McpServerCatalogPort = createMcpServerCatalog(seedRegistry());
|
|
182
|
-
const calls: { name: string; ctx: MCPContext }[] = [];
|
|
183
|
-
const client = createMcpClientPort({
|
|
184
|
-
async executeTool(
|
|
185
|
-
name: string,
|
|
186
|
-
args: Record<string, unknown>,
|
|
187
|
-
ctx: MCPContext,
|
|
188
|
-
): Promise<ToolExecutionResult> {
|
|
189
|
-
calls.push({ name, ctx });
|
|
190
|
-
return { success: true, result: { echoed: args }, duration: 1 };
|
|
191
|
-
},
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
const registry = new ToolRegistry();
|
|
195
|
-
const result = await activateMcpTools(registry, catalog, client, {
|
|
196
|
-
tenantId: "tenant-1",
|
|
197
|
-
plan: "pro",
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
expect([...result.registered].sort()).toEqual(["alerts", "forecast"]);
|
|
201
|
-
expect(result.skipped).toEqual([]);
|
|
202
|
-
|
|
203
|
-
const out = await registry.dispatch(
|
|
204
|
-
"forecast",
|
|
205
|
-
{ city: "NYC" },
|
|
206
|
-
{ tenantId: "tenant-1", threadId: "thread-1" },
|
|
207
|
-
);
|
|
208
|
-
|
|
209
|
-
expect(out).toEqual({ echoed: { city: "NYC" } });
|
|
210
|
-
expect(calls).toHaveLength(1);
|
|
211
|
-
expect(calls[0]?.ctx.tenantId).toBe("tenant-1");
|
|
212
|
-
});
|
|
213
|
-
});
|
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MCP catalog adapter — concrete wiring of `@nebutra/mcp` into
|
|
3
|
-
* `@nebutra/agent-runtime`'s injectable ports.
|
|
4
|
-
*
|
|
5
|
-
* `@nebutra/agent-runtime` is intentionally dependency-free: it declares the
|
|
6
|
-
* `McpServerCatalogPort` / `McpClientLike` ports and an `activateMcpTools`
|
|
7
|
-
* composer, but never imports `@nebutra/mcp`. This module supplies the only
|
|
8
|
-
* concrete bridge so apps/backends reuse it instead of re-implementing glue.
|
|
9
|
-
*
|
|
10
|
-
* Invariants:
|
|
11
|
-
* - tenantId is mandatory and validated *before* any registry/client call
|
|
12
|
-
* (fail-closed): an empty/blank tenant can never enumerate or execute.
|
|
13
|
-
* - Plan/tenant gating is delegated to `MCPServerRegistry` — a tenant only
|
|
14
|
-
* ever sees the tools its plan/tenant policy permits.
|
|
15
|
-
* - MCP `parameters` are translated into a Zod `inputSchema`; a tool with no
|
|
16
|
-
* usable schema still yields a permissive, never-crashing definition.
|
|
17
|
-
* - The MCP `ToolExecutionResult` envelope is unwrapped: a non-success
|
|
18
|
-
* result throws (fail-closed) rather than leaking a falsy value upstream.
|
|
19
|
-
*
|
|
20
|
-
* Both factories accept an injected dependency (registry / client) and default
|
|
21
|
-
* to the `@nebutra/mcp` singletons, keeping the module free of hidden globals
|
|
22
|
-
* and fully unit-testable.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import type { MCPContext, MCPServerRegistry, ToolExecutionResult } from "@nebutra/mcp";
|
|
26
|
-
import { mcpClient, serverRegistry } from "@nebutra/mcp";
|
|
27
|
-
import { z } from "zod";
|
|
28
|
-
import type { McpServerCatalogPort } from "../mcp-bridge";
|
|
29
|
-
import type { McpClientLike, ToolDefinition } from "../tools";
|
|
30
|
-
|
|
31
|
-
/** Narrow structural shapes — we depend on behaviour, not concrete classes. */
|
|
32
|
-
|
|
33
|
-
/** The slice of `MCPServerRegistry` the catalog port needs. */
|
|
34
|
-
export type McpRegistryLike = Pick<MCPServerRegistry, "getAccessibleTools">;
|
|
35
|
-
|
|
36
|
-
/** The slice of `mcpClient` the client port needs. */
|
|
37
|
-
export interface McpExecutorLike {
|
|
38
|
-
executeTool(
|
|
39
|
-
name: string,
|
|
40
|
-
args: Record<string, unknown>,
|
|
41
|
-
ctx: MCPContext,
|
|
42
|
-
): Promise<ToolExecutionResult>;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
type McpParameter = {
|
|
46
|
-
type?: "string" | "number" | "boolean" | "object" | "array";
|
|
47
|
-
description?: string;
|
|
48
|
-
required?: boolean;
|
|
49
|
-
default?: unknown;
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
type McpToolShape = {
|
|
53
|
-
name: string;
|
|
54
|
-
description?: string;
|
|
55
|
-
parameters?: Record<string, McpParameter> | undefined;
|
|
56
|
-
serverId: string;
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
/** Fail-closed tenant guard — the single chokepoint for both ports. */
|
|
60
|
-
const tenantSchema = z
|
|
61
|
-
.string({ message: "tenantId is required (fail-closed)" })
|
|
62
|
-
.trim()
|
|
63
|
-
.min(1, "tenantId is required (fail-closed)");
|
|
64
|
-
|
|
65
|
-
const catalogCtxSchema = z.object({
|
|
66
|
-
tenantId: tenantSchema,
|
|
67
|
-
plan: z.string().min(1).optional(),
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
const clientCtxSchema = z.object({
|
|
71
|
-
requestId: z.string().min(1, "requestId is required"),
|
|
72
|
-
tenantId: tenantSchema,
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
/** Map one MCP parameter to its Zod leaf type. Unknown → permissive. */
|
|
76
|
-
function paramToZod(param: McpParameter): z.ZodTypeAny {
|
|
77
|
-
switch (param.type) {
|
|
78
|
-
case "string":
|
|
79
|
-
return z.string();
|
|
80
|
-
case "number":
|
|
81
|
-
return z.number();
|
|
82
|
-
case "boolean":
|
|
83
|
-
return z.boolean();
|
|
84
|
-
case "array":
|
|
85
|
-
return z.array(z.unknown());
|
|
86
|
-
case "object":
|
|
87
|
-
return z.record(z.string(), z.unknown());
|
|
88
|
-
default:
|
|
89
|
-
return z.unknown();
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Build a Zod `inputSchema` from an MCP tool's `parameters`. A tool with no
|
|
95
|
-
* declared parameters becomes a permissive passthrough object (never throws on
|
|
96
|
-
* unknown keys) so schema-less MCP tools remain dispatchable.
|
|
97
|
-
*/
|
|
98
|
-
function toInputSchema(parameters: Record<string, McpParameter> | undefined): z.ZodType {
|
|
99
|
-
const entries = parameters ? Object.entries(parameters) : [];
|
|
100
|
-
if (entries.length === 0) {
|
|
101
|
-
return z.record(z.string(), z.unknown());
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const shape: Record<string, z.ZodTypeAny> = {};
|
|
105
|
-
for (const [key, param] of entries) {
|
|
106
|
-
let leaf = paramToZod(param);
|
|
107
|
-
if (param.required !== true) {
|
|
108
|
-
leaf = leaf.optional();
|
|
109
|
-
}
|
|
110
|
-
shape[key] = leaf;
|
|
111
|
-
}
|
|
112
|
-
// `passthrough` keeps unknown keys: MCP schemas are advisory, not strict.
|
|
113
|
-
return z.object(shape).passthrough();
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Adapt one registry tool entry into an agent-runtime `ToolDefinition`. */
|
|
117
|
-
function toToolDefinition(tool: McpToolShape): ToolDefinition {
|
|
118
|
-
return {
|
|
119
|
-
name: tool.name,
|
|
120
|
-
description: tool.description ?? tool.name,
|
|
121
|
-
inputSchema: toInputSchema(tool.parameters),
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Adapt `MCPServerRegistry`'s plan/tenant-gated tool listing into the
|
|
127
|
-
* agent-runtime `McpServerCatalogPort`.
|
|
128
|
-
*
|
|
129
|
-
* @param registry Defaults to `@nebutra/mcp`'s `serverRegistry` singleton;
|
|
130
|
-
* inject a fresh `MCPServerRegistry` for tests/multi-tenant
|
|
131
|
-
* isolation.
|
|
132
|
-
*/
|
|
133
|
-
export function createMcpServerCatalog(
|
|
134
|
-
registry: McpRegistryLike = serverRegistry,
|
|
135
|
-
): McpServerCatalogPort {
|
|
136
|
-
return {
|
|
137
|
-
async listTools(ctx) {
|
|
138
|
-
const scope = catalogCtxSchema.parse(ctx); // fail-closed first
|
|
139
|
-
|
|
140
|
-
const mcpCtx: MCPContext = {
|
|
141
|
-
requestId: "catalog-list",
|
|
142
|
-
tenantId: scope.tenantId,
|
|
143
|
-
...(scope.plan === undefined ? {} : { plan: scope.plan }),
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
const tools = registry.getAccessibleTools(mcpCtx) as McpToolShape[];
|
|
147
|
-
|
|
148
|
-
return tools.map((tool) => ({
|
|
149
|
-
server: tool.serverId,
|
|
150
|
-
definition: toToolDefinition(tool),
|
|
151
|
-
}));
|
|
152
|
-
},
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Adapt `@nebutra/mcp`'s `mcpClient` into agent-runtime's `McpClientLike`.
|
|
158
|
-
*
|
|
159
|
-
* Maps `{ requestId, tenantId }` → the richer `MCPContext`, and unwraps the
|
|
160
|
-
* `ToolExecutionResult` envelope: a non-success result throws so a failed MCP
|
|
161
|
-
* call never silently surfaces as a falsy tool output.
|
|
162
|
-
*
|
|
163
|
-
* @param client Defaults to `@nebutra/mcp`'s `mcpClient` singleton;
|
|
164
|
-
* inject a fake for tests.
|
|
165
|
-
*/
|
|
166
|
-
export function createMcpClientPort(client: McpExecutorLike = mcpClient): McpClientLike {
|
|
167
|
-
return {
|
|
168
|
-
async executeTool(name, args, ctx) {
|
|
169
|
-
const scope = clientCtxSchema.parse(ctx); // fail-closed first
|
|
170
|
-
|
|
171
|
-
const mcpCtx: MCPContext = {
|
|
172
|
-
requestId: scope.requestId,
|
|
173
|
-
tenantId: scope.tenantId,
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
const result = await client.executeTool(
|
|
177
|
-
name,
|
|
178
|
-
(args ?? {}) as Record<string, unknown>,
|
|
179
|
-
mcpCtx,
|
|
180
|
-
);
|
|
181
|
-
|
|
182
|
-
if (!result.success) {
|
|
183
|
-
throw new Error(`MCP tool "${name}" failed: ${result.error ?? "unknown error"}`);
|
|
184
|
-
}
|
|
185
|
-
return result.result;
|
|
186
|
-
},
|
|
187
|
-
};
|
|
188
|
-
}
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { type ApprovalGate, type ModelInvoker, runTurn } from "../loop";
|
|
3
|
-
import { type RolloutLine, replay } from "../rollout";
|
|
4
|
-
import { PersistentRolloutStore } from "../rollout-store-persistent";
|
|
5
|
-
import { ToolRegistry } from "../tools";
|
|
6
|
-
import { createPrismaRolloutPersistence, type PrismaRolloutDelegate } from "./prisma-rollout.js";
|
|
7
|
-
|
|
8
|
-
/** In-memory fake mirroring the agent_rollout_lines table semantics. */
|
|
9
|
-
function fakeDelegate() {
|
|
10
|
-
const rows: { tenantId: string; threadId: string; seq: number; at: Date; payload: string }[] = [];
|
|
11
|
-
const delegate: PrismaRolloutDelegate = {
|
|
12
|
-
async create({ data }) {
|
|
13
|
-
if (
|
|
14
|
-
rows.some(
|
|
15
|
-
(r) => r.tenantId === data.tenantId && r.threadId === data.threadId && r.seq === data.seq,
|
|
16
|
-
)
|
|
17
|
-
) {
|
|
18
|
-
// Mirrors the unique (tenant,thread,seq) constraint — fail loud.
|
|
19
|
-
throw new Error("unique constraint violation");
|
|
20
|
-
}
|
|
21
|
-
rows.push({ ...data });
|
|
22
|
-
return data;
|
|
23
|
-
},
|
|
24
|
-
async findMany({ where }) {
|
|
25
|
-
return rows
|
|
26
|
-
.filter((r) => r.tenantId === where.tenantId && r.threadId === where.threadId)
|
|
27
|
-
.sort((a, b) => a.seq - b.seq)
|
|
28
|
-
.map((r) => ({ seq: r.seq, payload: r.payload }));
|
|
29
|
-
},
|
|
30
|
-
};
|
|
31
|
-
return { delegate, rows };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const line = (tenantId: string, threadId: string): RolloutLine => ({
|
|
35
|
-
tenantId,
|
|
36
|
-
threadId,
|
|
37
|
-
type: "session_meta",
|
|
38
|
-
config: {
|
|
39
|
-
model: "m",
|
|
40
|
-
provider: "p",
|
|
41
|
-
approvalPolicy: "on_request",
|
|
42
|
-
capabilityPolicy: "external_sandbox",
|
|
43
|
-
},
|
|
44
|
-
at: new Date().toISOString(),
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
describe("createPrismaRolloutPersistence", () => {
|
|
48
|
-
it("round-trips through PersistentRolloutStore and replays faithfully", async () => {
|
|
49
|
-
const { delegate } = fakeDelegate();
|
|
50
|
-
const store = new PersistentRolloutStore(createPrismaRolloutPersistence(delegate));
|
|
51
|
-
await store.append(line("org_a", "th_1"));
|
|
52
|
-
await store.append({
|
|
53
|
-
tenantId: "org_a",
|
|
54
|
-
threadId: "th_1",
|
|
55
|
-
type: "event",
|
|
56
|
-
at: new Date().toISOString(),
|
|
57
|
-
event: { type: "item.completed", item: { id: "i1", type: "agent_message", text: "hi" } },
|
|
58
|
-
});
|
|
59
|
-
const proj = replay(await store.read("org_a", "th_1"));
|
|
60
|
-
expect(proj?.items.map((i) => i.id)).toEqual(["i1"]);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("isolates tenants — B cannot read A's thread", async () => {
|
|
64
|
-
const { delegate } = fakeDelegate();
|
|
65
|
-
const store = new PersistentRolloutStore(createPrismaRolloutPersistence(delegate));
|
|
66
|
-
await store.append(line("org_a", "th_1"));
|
|
67
|
-
expect(await store.read("org_b", "th_1")).toHaveLength(0);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("fails loud on a persistence rejection (never silently drops)", async () => {
|
|
71
|
-
const failing: PrismaRolloutDelegate = {
|
|
72
|
-
async create() {
|
|
73
|
-
throw new Error("db down");
|
|
74
|
-
},
|
|
75
|
-
async findMany() {
|
|
76
|
-
return [];
|
|
77
|
-
},
|
|
78
|
-
};
|
|
79
|
-
const port = createPrismaRolloutPersistence(failing);
|
|
80
|
-
await expect(
|
|
81
|
-
port.put({
|
|
82
|
-
tenantId: "t",
|
|
83
|
-
threadId: "th",
|
|
84
|
-
seq: 0,
|
|
85
|
-
at: new Date().toISOString(),
|
|
86
|
-
payload: "{}",
|
|
87
|
-
}),
|
|
88
|
-
).rejects.toThrow("db down");
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("fails closed on empty tenant / bad timestamp", async () => {
|
|
92
|
-
const { delegate } = fakeDelegate();
|
|
93
|
-
const port = createPrismaRolloutPersistence(delegate);
|
|
94
|
-
await expect(
|
|
95
|
-
port.put({ tenantId: "", threadId: "th", seq: 0, at: "x", payload: "{}" }),
|
|
96
|
-
).rejects.toBeTruthy();
|
|
97
|
-
await expect(
|
|
98
|
-
port.put({ tenantId: "t", threadId: "th", seq: 0, at: "not-a-date", payload: "{}" }),
|
|
99
|
-
).rejects.toThrow(/invalid timestamp/);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("resolves a per-tenant delegate (RLS client per call)", async () => {
|
|
103
|
-
const a = fakeDelegate();
|
|
104
|
-
const b = fakeDelegate();
|
|
105
|
-
const port = createPrismaRolloutPersistence((tenantId) =>
|
|
106
|
-
tenantId === "org_a" ? a.delegate : b.delegate,
|
|
107
|
-
);
|
|
108
|
-
await port.put({
|
|
109
|
-
tenantId: "org_a",
|
|
110
|
-
threadId: "th",
|
|
111
|
-
seq: 0,
|
|
112
|
-
at: new Date().toISOString(),
|
|
113
|
-
payload: "{}",
|
|
114
|
-
});
|
|
115
|
-
expect(a.rows).toHaveLength(1);
|
|
116
|
-
expect(b.rows).toHaveLength(0);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("drives a full turn whose rollout survives in the durable backend", async () => {
|
|
120
|
-
const { delegate, rows } = fakeDelegate();
|
|
121
|
-
const store = new PersistentRolloutStore(createPrismaRolloutPersistence(delegate));
|
|
122
|
-
const model: ModelInvoker = {
|
|
123
|
-
async invoke() {
|
|
124
|
-
return { emissions: [{ kind: "text", text: "done" }] };
|
|
125
|
-
},
|
|
126
|
-
};
|
|
127
|
-
const gate: ApprovalGate = {
|
|
128
|
-
async request() {
|
|
129
|
-
return { kind: "denied" };
|
|
130
|
-
},
|
|
131
|
-
};
|
|
132
|
-
const events = [];
|
|
133
|
-
for await (const e of runTurn("hi", {
|
|
134
|
-
tenantId: "org_a",
|
|
135
|
-
threadId: "th_x",
|
|
136
|
-
config: {
|
|
137
|
-
model: "m",
|
|
138
|
-
provider: "p",
|
|
139
|
-
approvalPolicy: "on_request",
|
|
140
|
-
capabilityPolicy: "external_sandbox",
|
|
141
|
-
},
|
|
142
|
-
approvalPolicy: { kind: "on_request" },
|
|
143
|
-
model,
|
|
144
|
-
tools: new ToolRegistry(),
|
|
145
|
-
store,
|
|
146
|
-
approvalGate: gate,
|
|
147
|
-
})) {
|
|
148
|
-
events.push(e);
|
|
149
|
-
}
|
|
150
|
-
expect(rows.length).toBe(events.length);
|
|
151
|
-
expect(rows.every((r) => r.tenantId === "org_a")).toBe(true);
|
|
152
|
-
});
|
|
153
|
-
});
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Durable rollout-store backend: `RolloutPersistencePort` over Prisma.
|
|
3
|
-
*
|
|
4
|
-
* This is the fail-loud system-of-record adapter (table `agent_rollout_lines`,
|
|
5
|
-
* migration `20260519000000_add_agent_rollout_store`). Unlike a best-effort
|
|
6
|
-
* audit log it never swallows a failed write — a rejected `put` propagates so
|
|
7
|
-
* the caller's "never silently drop" contract holds.
|
|
8
|
-
*
|
|
9
|
-
* Reusability: it depends only on a minimal injected delegate (the
|
|
10
|
-
* `PrismaAuditDelegate` pattern), NOT on the generated Prisma client or
|
|
11
|
-
* `@nebutra/db`. Any tenant-scoped Prisma client (`getTenantDb(orgId)`)
|
|
12
|
-
* structurally satisfies it, so this package stays dependency-light.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { z } from "zod";
|
|
16
|
-
import type { RolloutPersistencePort } from "../rollout-store-persistent";
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Minimal structural slice of `prisma.agentRolloutLine`. Decoupled from the
|
|
20
|
-
* generated client on purpose — only these two operations are needed.
|
|
21
|
-
*/
|
|
22
|
-
export interface PrismaRolloutDelegate {
|
|
23
|
-
create(args: {
|
|
24
|
-
data: {
|
|
25
|
-
tenantId: string;
|
|
26
|
-
threadId: string;
|
|
27
|
-
seq: number;
|
|
28
|
-
at: Date;
|
|
29
|
-
payload: string;
|
|
30
|
-
};
|
|
31
|
-
}): Promise<unknown>;
|
|
32
|
-
findMany(args: {
|
|
33
|
-
where: { tenantId: string; threadId: string };
|
|
34
|
-
orderBy: { seq: "asc" };
|
|
35
|
-
select: { seq: true; payload: true };
|
|
36
|
-
}): Promise<{ seq: number; payload: string }[]>;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Resolves the delegate per call so a request-scoped (RLS) client can be used. */
|
|
40
|
-
export type RolloutDelegateResolver =
|
|
41
|
-
| PrismaRolloutDelegate
|
|
42
|
-
| ((tenantId: string) => PrismaRolloutDelegate | Promise<PrismaRolloutDelegate>);
|
|
43
|
-
|
|
44
|
-
const recordSchema = z.object({
|
|
45
|
-
tenantId: z.string().min(1, "tenantId is required"),
|
|
46
|
-
threadId: z.string().min(1, "threadId is required"),
|
|
47
|
-
seq: z.number().int().nonnegative(),
|
|
48
|
-
at: z.string().min(1),
|
|
49
|
-
payload: z.string(),
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
const querySchema = z.object({
|
|
53
|
-
tenantId: z.string().min(1, "tenantId is required"),
|
|
54
|
-
threadId: z.string().min(1, "threadId is required"),
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
async function resolve(
|
|
58
|
-
resolver: RolloutDelegateResolver,
|
|
59
|
-
tenantId: string,
|
|
60
|
-
): Promise<PrismaRolloutDelegate> {
|
|
61
|
-
return typeof resolver === "function" ? resolver(tenantId) : resolver;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Build a {@link RolloutPersistencePort} backed by Prisma.
|
|
66
|
-
*
|
|
67
|
-
* @param delegate a `PrismaRolloutDelegate`, or a resolver mapping a tenantId
|
|
68
|
-
* to one (use the latter to obtain a per-tenant RLS client per call).
|
|
69
|
-
*/
|
|
70
|
-
export function createPrismaRolloutPersistence(
|
|
71
|
-
delegate: RolloutDelegateResolver,
|
|
72
|
-
): RolloutPersistencePort {
|
|
73
|
-
return {
|
|
74
|
-
async put(record): Promise<void> {
|
|
75
|
-
const r = recordSchema.parse(record);
|
|
76
|
-
const at = new Date(r.at);
|
|
77
|
-
if (Number.isNaN(at.getTime())) {
|
|
78
|
-
throw new Error(`invalid timestamp: ${r.at}`);
|
|
79
|
-
}
|
|
80
|
-
const db = await resolve(delegate, r.tenantId);
|
|
81
|
-
// Fail-loud: any persistence rejection propagates to the caller.
|
|
82
|
-
await db.create({
|
|
83
|
-
data: {
|
|
84
|
-
tenantId: r.tenantId,
|
|
85
|
-
threadId: r.threadId,
|
|
86
|
-
seq: r.seq,
|
|
87
|
-
at,
|
|
88
|
-
payload: r.payload,
|
|
89
|
-
},
|
|
90
|
-
});
|
|
91
|
-
},
|
|
92
|
-
|
|
93
|
-
async list(tenantId, threadId): Promise<{ seq: number; payload: string }[]> {
|
|
94
|
-
const q = querySchema.parse({ tenantId, threadId });
|
|
95
|
-
const db = await resolve(delegate, q.tenantId);
|
|
96
|
-
const rows = await db.findMany({
|
|
97
|
-
where: { tenantId: q.tenantId, threadId: q.threadId },
|
|
98
|
-
orderBy: { seq: "asc" },
|
|
99
|
-
select: { seq: true, payload: true },
|
|
100
|
-
});
|
|
101
|
-
return rows.map((row) => ({ seq: row.seq, payload: row.payload }));
|
|
102
|
-
},
|
|
103
|
-
};
|
|
104
|
-
}
|