@nebutra/agent-runtime 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/LICENSE +21 -676
- package/README.md +2 -0
- package/dist/adapters/dispatcher-sse.js +1 -0
- package/dist/adapters/index.d.ts +24 -5
- package/dist/adapters/index.js +28 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/adapters/mcp-catalog.js +1 -0
- package/dist/adapters/prisma-rollout.js +1 -0
- package/dist/chunk-424PT5DM.js +23 -0
- package/dist/chunk-424PT5DM.js.map +1 -0
- package/dist/{chunk-NN7DATXA.js → chunk-4Y25ZTKI.js} +3 -3
- package/dist/chunk-4Y25ZTKI.js.map +1 -0
- package/dist/{chunk-BJBBR3QA.js → chunk-D4YAPLOW.js} +4 -4
- package/dist/chunk-D4YAPLOW.js.map +1 -0
- package/dist/{chunk-ZMYX5VBU.js → chunk-GQZKYWFT.js} +23 -7
- package/dist/chunk-GQZKYWFT.js.map +1 -0
- package/dist/chunk-KCNN4QUQ.js +255 -0
- package/dist/chunk-KCNN4QUQ.js.map +1 -0
- package/dist/{chunk-PGGWSUTM.js → chunk-NI4EDT4T.js} +2 -2
- package/dist/chunk-NI4EDT4T.js.map +1 -0
- package/dist/chunk-Q62VKHIT.js +178 -0
- package/dist/chunk-Q62VKHIT.js.map +1 -0
- package/dist/{chunk-MUF7ZZTO.js → chunk-R5HOSQUW.js} +2 -1
- package/dist/{chunk-MUF7ZZTO.js.map → chunk-R5HOSQUW.js.map} +1 -1
- package/dist/{chunk-5N4644PB.js → chunk-SD2ZJ7XG.js} +4 -4
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +52 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands.js +1 -0
- package/dist/definitions.js +1 -0
- package/dist/dispatcher.js +1 -0
- package/dist/durable-turn.js +3 -2
- package/dist/hook-pipeline.js +1 -0
- package/dist/index.d.ts +8 -85
- package/dist/index.js +249 -134
- package/dist/index.js.map +1 -1
- package/dist/loop.d.ts +2 -2
- package/dist/loop.js +3 -2
- package/dist/mcp-bridge.d.ts +3 -3
- package/dist/mcp-bridge.js +3 -2
- package/dist/model.js +1 -0
- package/dist/orchestration.d.ts +84 -0
- package/dist/orchestration.js +16 -0
- package/dist/orchestration.js.map +1 -0
- package/dist/policy.js +1 -0
- package/dist/protocol.js +1 -0
- package/dist/pulsar.d.ts +78 -0
- package/dist/pulsar.js +17 -0
- package/dist/pulsar.js.map +1 -0
- package/dist/rollout-store-persistent.js +1 -0
- package/dist/rollout.js +1 -0
- package/dist/sandbox.js +2 -1
- package/dist/skills.js +2 -1
- package/dist/subagents.js +1 -0
- package/dist/tools.d.ts +4 -3
- package/dist/tools.js +5 -3
- package/package.json +84 -27
- package/.turbo/turbo-build.log +0 -115
- package/.turbo/turbo-test.log +0 -44
- package/.turbo/turbo-typecheck.log +0 -4
- package/CHANGELOG.md +0 -253
- package/dist/chunk-BJBBR3QA.js.map +0 -1
- package/dist/chunk-NN7DATXA.js.map +0 -1
- package/dist/chunk-PGGWSUTM.js.map +0 -1
- package/dist/chunk-ZMYX5VBU.js.map +0 -1
- 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/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 -434
- 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 -47
- 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 -76
- package/src/memory-provider.test.ts +0 -232
- package/src/memory-provider.ts +0 -257
- package/src/model.ts +0 -168
- package/src/permission-ruleset.test.ts +0 -301
- package/src/permission-ruleset.ts +0 -200
- 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/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 -255
- package/src/subagents.test.ts +0 -290
- package/src/subagents.ts +0 -332
- package/src/tools.ts +0 -126
- package/src/workbench.test.ts +0 -0
- package/src/workbench.ts +0 -0
- package/tsconfig.json +0 -12
- package/tsup.config.ts +0 -33
- /package/dist/{chunk-5N4644PB.js.map → chunk-SD2ZJ7XG.js.map} +0 -0
package/src/loop.test.ts
DELETED
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import { type ApprovalGate, type ModelInvoker, type ModelRoundResult, runTurn } from "./loop";
|
|
4
|
-
import type { ThreadEvent, TurnConfig } from "./model";
|
|
5
|
-
import type { ReviewDecision } from "./policy";
|
|
6
|
-
import { InMemoryRolloutStore } from "./rollout";
|
|
7
|
-
import { ToolRegistry } from "./tools";
|
|
8
|
-
|
|
9
|
-
const config: TurnConfig = {
|
|
10
|
-
model: "m",
|
|
11
|
-
provider: "p",
|
|
12
|
-
approvalPolicy: "on_request",
|
|
13
|
-
capabilityPolicy: "external_sandbox",
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
const approveAll: ApprovalGate = {
|
|
17
|
-
async request() {
|
|
18
|
-
return { kind: "approved" } as ReviewDecision;
|
|
19
|
-
},
|
|
20
|
-
};
|
|
21
|
-
const denyAll: ApprovalGate = {
|
|
22
|
-
async request() {
|
|
23
|
-
return { kind: "denied" } as ReviewDecision;
|
|
24
|
-
},
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
/** Model that calls `echo` once, then finishes with text. */
|
|
28
|
-
function scriptedModel(): ModelInvoker {
|
|
29
|
-
let round = 0;
|
|
30
|
-
return {
|
|
31
|
-
async invoke(): Promise<ModelRoundResult> {
|
|
32
|
-
round += 1;
|
|
33
|
-
if (round === 1) {
|
|
34
|
-
return {
|
|
35
|
-
emissions: [{ kind: "tool_call", id: "tc_1", name: "echo", args: { v: "hi" } }],
|
|
36
|
-
usage: { outputTokens: 5 },
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
return { emissions: [{ kind: "text", text: "done" }] };
|
|
40
|
-
},
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function registry(): ToolRegistry {
|
|
45
|
-
const reg = new ToolRegistry();
|
|
46
|
-
reg.register(
|
|
47
|
-
{ name: "echo", description: "echo", inputSchema: z.object({ v: z.string() }) },
|
|
48
|
-
async (input: { v: string }, ctx) => `${ctx.tenantId}:${input.v}`,
|
|
49
|
-
);
|
|
50
|
-
return reg;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async function collect(gen: AsyncGenerator<ThreadEvent>): Promise<ThreadEvent[]> {
|
|
54
|
-
const out: ThreadEvent[] = [];
|
|
55
|
-
for await (const e of gen) out.push(e);
|
|
56
|
-
return out;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
describe("loop runner", () => {
|
|
60
|
-
it("drives model → tool → result → completion and persists the rollout", async () => {
|
|
61
|
-
const store = new InMemoryRolloutStore();
|
|
62
|
-
const events = await collect(
|
|
63
|
-
runTurn("do it", {
|
|
64
|
-
tenantId: "org_a",
|
|
65
|
-
threadId: "th_1",
|
|
66
|
-
config,
|
|
67
|
-
approvalPolicy: { kind: "on_request" },
|
|
68
|
-
model: scriptedModel(),
|
|
69
|
-
tools: registry(),
|
|
70
|
-
store,
|
|
71
|
-
approvalGate: approveAll,
|
|
72
|
-
ruleEvaluator: () => "allow",
|
|
73
|
-
}),
|
|
74
|
-
);
|
|
75
|
-
const types = events.map((e) => e.type);
|
|
76
|
-
expect(types[0]).toBe("turn.started");
|
|
77
|
-
expect(types).toContain("item.completed");
|
|
78
|
-
expect(types[types.length - 1]).toBe("turn.completed");
|
|
79
|
-
|
|
80
|
-
const lines = await store.read("org_a", "th_1");
|
|
81
|
-
expect(lines.length).toBe(events.length); // every event persisted, tenant-scoped
|
|
82
|
-
expect(lines.every((l) => l.tenantId === "org_a")).toBe(true);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("fails closed when a tool is not approved — never dispatches it", async () => {
|
|
86
|
-
let dispatched = false;
|
|
87
|
-
const reg = new ToolRegistry();
|
|
88
|
-
reg.register(
|
|
89
|
-
{ name: "echo", description: "echo", inputSchema: z.object({ v: z.string() }) },
|
|
90
|
-
async () => {
|
|
91
|
-
dispatched = true;
|
|
92
|
-
return "ran";
|
|
93
|
-
},
|
|
94
|
-
);
|
|
95
|
-
const events = await collect(
|
|
96
|
-
runTurn("do it", {
|
|
97
|
-
tenantId: "org_a",
|
|
98
|
-
threadId: "th_1",
|
|
99
|
-
config,
|
|
100
|
-
approvalPolicy: { kind: "on_request" },
|
|
101
|
-
model: scriptedModel(),
|
|
102
|
-
tools: reg,
|
|
103
|
-
store: new InMemoryRolloutStore(),
|
|
104
|
-
approvalGate: denyAll,
|
|
105
|
-
ruleEvaluator: () => "prompt",
|
|
106
|
-
}),
|
|
107
|
-
);
|
|
108
|
-
expect(dispatched).toBe(false);
|
|
109
|
-
expect(events.some((e) => e.type === "item.completed" && e.item.type === "error")).toBe(true);
|
|
110
|
-
expect(events[events.length - 1]?.type).toBe("turn.completed");
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("surfaces an internal failure as turn.failed, never throws to caller", async () => {
|
|
114
|
-
const brokenModel: ModelInvoker = {
|
|
115
|
-
async invoke() {
|
|
116
|
-
throw new Error("model exploded");
|
|
117
|
-
},
|
|
118
|
-
};
|
|
119
|
-
const events = await collect(
|
|
120
|
-
runTurn("x", {
|
|
121
|
-
tenantId: "t",
|
|
122
|
-
threadId: "th",
|
|
123
|
-
config,
|
|
124
|
-
approvalPolicy: { kind: "on_request" },
|
|
125
|
-
model: brokenModel,
|
|
126
|
-
tools: new ToolRegistry(),
|
|
127
|
-
store: new InMemoryRolloutStore(),
|
|
128
|
-
approvalGate: approveAll,
|
|
129
|
-
}),
|
|
130
|
-
);
|
|
131
|
-
expect(events[events.length - 1]).toEqual({
|
|
132
|
-
type: "turn.failed",
|
|
133
|
-
error: { message: "model exploded" },
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it("respects the bounded step ceiling", async () => {
|
|
138
|
-
const loopingModel: ModelInvoker = {
|
|
139
|
-
async invoke() {
|
|
140
|
-
return { emissions: [{ kind: "tool_call", id: "x", name: "echo", args: { v: "1" } }] };
|
|
141
|
-
},
|
|
142
|
-
};
|
|
143
|
-
const events = await collect(
|
|
144
|
-
runTurn("x", {
|
|
145
|
-
tenantId: "t",
|
|
146
|
-
threadId: "th",
|
|
147
|
-
config,
|
|
148
|
-
approvalPolicy: { kind: "on_request" },
|
|
149
|
-
model: loopingModel,
|
|
150
|
-
tools: registry(),
|
|
151
|
-
store: new InMemoryRolloutStore(),
|
|
152
|
-
approvalGate: approveAll,
|
|
153
|
-
ruleEvaluator: () => "allow",
|
|
154
|
-
maxSteps: 3,
|
|
155
|
-
}),
|
|
156
|
-
);
|
|
157
|
-
// 3 steps × 1 tool item + turn.started + turn.completed
|
|
158
|
-
expect(events.filter((e) => e.type === "item.completed")).toHaveLength(3);
|
|
159
|
-
expect(events[events.length - 1]?.type).toBe("turn.completed");
|
|
160
|
-
});
|
|
161
|
-
});
|
package/src/loop.ts
DELETED
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Agent loop runner (WRAP — the turn engine).
|
|
3
|
-
*
|
|
4
|
-
* Faithful re-expression of the upstream loop: a turn is
|
|
5
|
-
* `loop { model_call → emit items → execute tools → feed results back }`
|
|
6
|
-
* until the model stops requesting tools or a bounded step ceiling is hit.
|
|
7
|
-
* Single-threaded (Cognition teaching: shared context, no conflicting
|
|
8
|
-
* sub-agent decisions). Every item is appended to the tenant-scoped rollout
|
|
9
|
-
* as it reaches a terminal state, so the turn is resumable by replay.
|
|
10
|
-
*
|
|
11
|
-
* The model call is abstracted behind {@link ModelInvoker} so this WRAPs an
|
|
12
|
-
* existing model stack (e.g. `@nebutra/agents`) rather than re-porting
|
|
13
|
-
* provider/routing/fallback. No untrusted code runs here — command items are
|
|
14
|
-
* dispatched through the tool registry / external-sandbox seam.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import type { AgentMessageItem, ThreadEvent, ThreadItem, TurnConfig, TurnUsage } from "./model";
|
|
18
|
-
import {
|
|
19
|
-
type ApprovalPolicy,
|
|
20
|
-
DENIED,
|
|
21
|
-
isApproval,
|
|
22
|
-
type ReviewDecision,
|
|
23
|
-
type RuleDecision,
|
|
24
|
-
resolveRuleDecision,
|
|
25
|
-
} from "./policy";
|
|
26
|
-
import type { ServerRequest } from "./protocol";
|
|
27
|
-
import { type RolloutLine, type RolloutStore, sanitizeForPersist } from "./rollout";
|
|
28
|
-
import type { ToolDispatchContext, ToolRegistry } from "./tools";
|
|
29
|
-
|
|
30
|
-
/** A single thing the model emitted in one round. */
|
|
31
|
-
export type ModelEmission =
|
|
32
|
-
| { readonly kind: "text"; readonly text: string }
|
|
33
|
-
| {
|
|
34
|
-
readonly kind: "tool_call";
|
|
35
|
-
readonly id: string;
|
|
36
|
-
readonly name: string;
|
|
37
|
-
readonly args: unknown;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
export interface ModelRoundResult {
|
|
41
|
-
readonly emissions: readonly ModelEmission[];
|
|
42
|
-
readonly usage?: Partial<TurnUsage>;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface ModelRoundRequest {
|
|
46
|
-
readonly config: TurnConfig;
|
|
47
|
-
/** Running transcript: user input, agent text, and tool results. */
|
|
48
|
-
readonly history: readonly { readonly role: string; readonly content: string }[];
|
|
49
|
-
readonly toolNames: readonly string[];
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Abstracts the model stack — implement over `@nebutra/agents`, etc. */
|
|
53
|
-
export interface ModelInvoker {
|
|
54
|
-
invoke(request: ModelRoundRequest): Promise<ModelRoundResult>;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Server-initiated approval transport (see {@link ServerRequest}). */
|
|
58
|
-
export interface ApprovalGate {
|
|
59
|
-
request(serverRequest: ServerRequest): Promise<ReviewDecision>;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Classifies a tool call into a static rule decision before approval. */
|
|
63
|
-
export type RuleEvaluator = (toolName: string, args: unknown) => RuleDecision;
|
|
64
|
-
|
|
65
|
-
export interface RunTurnDeps {
|
|
66
|
-
readonly tenantId: string;
|
|
67
|
-
readonly threadId: string;
|
|
68
|
-
readonly config: TurnConfig;
|
|
69
|
-
readonly approvalPolicy: ApprovalPolicy;
|
|
70
|
-
readonly model: ModelInvoker;
|
|
71
|
-
readonly tools: ToolRegistry;
|
|
72
|
-
readonly store: RolloutStore;
|
|
73
|
-
readonly approvalGate: ApprovalGate;
|
|
74
|
-
/** Defaults to: everything requires a prompt (safe). */
|
|
75
|
-
readonly ruleEvaluator?: RuleEvaluator;
|
|
76
|
-
/** Bounded steps (parity with upstream step ceiling). Default 20. */
|
|
77
|
-
readonly maxSteps?: number;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const DEFAULT_MAX_STEPS = 20;
|
|
81
|
-
const requirePrompt: RuleEvaluator = () => "prompt";
|
|
82
|
-
|
|
83
|
-
function newId(prefix: string): string {
|
|
84
|
-
return `${prefix}_${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`}`;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Drive one turn to completion. Yields the {@link ThreadEvent} stream and
|
|
89
|
-
* appends each terminal item + the turn outcome to the rollout store.
|
|
90
|
-
* Never throws to the caller — failures surface as a `turn.failed` event.
|
|
91
|
-
*/
|
|
92
|
-
export async function* runTurn(userInput: string, deps: RunTurnDeps): AsyncGenerator<ThreadEvent> {
|
|
93
|
-
const at = () => new Date().toISOString();
|
|
94
|
-
const ruleEvaluator = deps.ruleEvaluator ?? requirePrompt;
|
|
95
|
-
const maxSteps = deps.maxSteps ?? DEFAULT_MAX_STEPS;
|
|
96
|
-
const ctx: ToolDispatchContext = { tenantId: deps.tenantId, threadId: deps.threadId };
|
|
97
|
-
|
|
98
|
-
const append = async (line: RolloutLine): Promise<void> => deps.store.append(line);
|
|
99
|
-
const event = async (e: ThreadEvent): Promise<ThreadEvent> => {
|
|
100
|
-
await append({
|
|
101
|
-
tenantId: deps.tenantId,
|
|
102
|
-
threadId: deps.threadId,
|
|
103
|
-
type: "event",
|
|
104
|
-
event:
|
|
105
|
-
e.type === "item.completed"
|
|
106
|
-
? { type: "item.completed", item: sanitizeForPersist(e.item) }
|
|
107
|
-
: e,
|
|
108
|
-
at: at(),
|
|
109
|
-
});
|
|
110
|
-
return e;
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
yield await event({ type: "turn.started" });
|
|
114
|
-
|
|
115
|
-
const history: { role: string; content: string }[] = [{ role: "user", content: userInput }];
|
|
116
|
-
let usage: TurnUsage = {
|
|
117
|
-
inputTokens: 0,
|
|
118
|
-
cachedInputTokens: 0,
|
|
119
|
-
outputTokens: 0,
|
|
120
|
-
reasoningOutputTokens: 0,
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
try {
|
|
124
|
-
for (let step = 0; step < maxSteps; step++) {
|
|
125
|
-
const round = await deps.model.invoke({
|
|
126
|
-
config: deps.config,
|
|
127
|
-
history,
|
|
128
|
-
toolNames: deps.tools.list().map((t) => t.definition.name),
|
|
129
|
-
});
|
|
130
|
-
if (round.usage) {
|
|
131
|
-
const u = round.usage;
|
|
132
|
-
usage = {
|
|
133
|
-
inputTokens: usage.inputTokens + (u.inputTokens ?? 0),
|
|
134
|
-
cachedInputTokens: usage.cachedInputTokens + (u.cachedInputTokens ?? 0),
|
|
135
|
-
outputTokens: usage.outputTokens + (u.outputTokens ?? 0),
|
|
136
|
-
reasoningOutputTokens: usage.reasoningOutputTokens + (u.reasoningOutputTokens ?? 0),
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const toolCalls = round.emissions.filter(
|
|
141
|
-
(e): e is Extract<ModelEmission, { kind: "tool_call" }> => e.kind === "tool_call",
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
for (const e of round.emissions) {
|
|
145
|
-
if (e.kind !== "text") continue;
|
|
146
|
-
const item: AgentMessageItem = { id: newId("msg"), type: "agent_message", text: e.text };
|
|
147
|
-
history.push({ role: "assistant", content: e.text });
|
|
148
|
-
yield await event({ type: "item.completed", item });
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (toolCalls.length === 0) break; // model is done
|
|
152
|
-
|
|
153
|
-
for (const call of toolCalls) {
|
|
154
|
-
const decision = await gateToolCall(call.name, call.args, deps, ruleEvaluator);
|
|
155
|
-
if (!isApproval(decision)) {
|
|
156
|
-
const failed: ThreadItem = {
|
|
157
|
-
id: call.id,
|
|
158
|
-
type: "error",
|
|
159
|
-
message: `tool '${call.name}' not approved (${decision.kind})`,
|
|
160
|
-
};
|
|
161
|
-
history.push({ role: "tool", content: `DENIED: ${call.name}` });
|
|
162
|
-
yield await event({ type: "item.completed", item: failed });
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
try {
|
|
166
|
-
const output = await deps.tools.dispatch(call.name, call.args, ctx);
|
|
167
|
-
const item: ThreadItem = {
|
|
168
|
-
id: call.id,
|
|
169
|
-
type: "mcp_tool_call",
|
|
170
|
-
server: "native",
|
|
171
|
-
tool: call.name,
|
|
172
|
-
arguments: call.args,
|
|
173
|
-
result: { content: output },
|
|
174
|
-
status: "completed",
|
|
175
|
-
};
|
|
176
|
-
history.push({ role: "tool", content: JSON.stringify(output) });
|
|
177
|
-
yield await event({ type: "item.completed", item });
|
|
178
|
-
} catch (err) {
|
|
179
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
180
|
-
history.push({ role: "tool", content: `ERROR: ${message}` });
|
|
181
|
-
yield await event({
|
|
182
|
-
type: "item.completed",
|
|
183
|
-
item: { id: call.id, type: "error", message },
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
yield await event({ type: "turn.completed", usage });
|
|
190
|
-
} catch (err) {
|
|
191
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
192
|
-
yield await event({ type: "turn.failed", error: { message } });
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** Resolve a tool call's approval, raising a server-initiated request if asked. */
|
|
197
|
-
async function gateToolCall(
|
|
198
|
-
name: string,
|
|
199
|
-
args: unknown,
|
|
200
|
-
deps: RunTurnDeps,
|
|
201
|
-
ruleEvaluator: RuleEvaluator,
|
|
202
|
-
): Promise<ReviewDecision> {
|
|
203
|
-
const outcome = resolveRuleDecision(ruleEvaluator(name, args), deps.approvalPolicy);
|
|
204
|
-
if (outcome === "auto_allow") return { kind: "approved" };
|
|
205
|
-
if (outcome === "auto_reject") return DENIED;
|
|
206
|
-
return deps.approvalGate.request({
|
|
207
|
-
type: "permissions.request_approval",
|
|
208
|
-
requestId: newId("appr"),
|
|
209
|
-
summary: `tool '${name}'`,
|
|
210
|
-
});
|
|
211
|
-
}
|
package/src/mcp-bridge.test.ts
DELETED
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
|
|
4
|
-
import { activateMcpTools, type McpServerCatalogPort } from "./mcp-bridge";
|
|
5
|
-
import { type McpClientLike, type ToolDefinition, ToolRegistry } from "./tools";
|
|
6
|
-
|
|
7
|
-
function def(name: string): ToolDefinition {
|
|
8
|
-
return {
|
|
9
|
-
name,
|
|
10
|
-
description: `tool ${name}`,
|
|
11
|
-
inputSchema: z.object({ q: z.string() }),
|
|
12
|
-
};
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** Catalog fake: tenant- and plan-scoped tool listings. */
|
|
16
|
-
function fakeCatalog(
|
|
17
|
-
byTenant: Record<
|
|
18
|
-
string,
|
|
19
|
-
{
|
|
20
|
-
free: { server: string; definition: ToolDefinition }[];
|
|
21
|
-
pro: { server: string; definition: ToolDefinition }[];
|
|
22
|
-
}
|
|
23
|
-
>,
|
|
24
|
-
): McpServerCatalogPort {
|
|
25
|
-
return {
|
|
26
|
-
async listTools(ctx) {
|
|
27
|
-
const t = byTenant[ctx.tenantId];
|
|
28
|
-
if (!t) return [];
|
|
29
|
-
return ctx.plan === "pro" ? t.pro : t.free;
|
|
30
|
-
},
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function fakeClient(): McpClientLike & {
|
|
35
|
-
calls: { name: string; args: unknown; tenantId: string }[];
|
|
36
|
-
} {
|
|
37
|
-
const calls: { name: string; args: unknown; tenantId: string }[] = [];
|
|
38
|
-
return {
|
|
39
|
-
calls,
|
|
40
|
-
async executeTool(name, args, ctx) {
|
|
41
|
-
calls.push({ name, args, tenantId: ctx.tenantId });
|
|
42
|
-
return { ok: true, name };
|
|
43
|
-
},
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
describe("activateMcpTools", () => {
|
|
48
|
-
it("registers tenant-scoped tools dispatchable through the registry with the right tenantId", async () => {
|
|
49
|
-
const registry = new ToolRegistry();
|
|
50
|
-
const catalog = fakeCatalog({
|
|
51
|
-
org_a: {
|
|
52
|
-
free: [{ server: "weather", definition: def("get_weather") }],
|
|
53
|
-
pro: [],
|
|
54
|
-
},
|
|
55
|
-
});
|
|
56
|
-
const client = fakeClient();
|
|
57
|
-
|
|
58
|
-
const res = await activateMcpTools(registry, catalog, client, { tenantId: "org_a" });
|
|
59
|
-
|
|
60
|
-
expect(res.registered).toEqual(["get_weather"]);
|
|
61
|
-
expect(res.skipped).toEqual([]);
|
|
62
|
-
expect(registry.list().map((r) => r.definition.name)).toEqual(["get_weather"]);
|
|
63
|
-
|
|
64
|
-
const out = await registry.dispatch(
|
|
65
|
-
"get_weather",
|
|
66
|
-
{ q: "NYC" },
|
|
67
|
-
{
|
|
68
|
-
tenantId: "org_a",
|
|
69
|
-
threadId: "t1",
|
|
70
|
-
},
|
|
71
|
-
);
|
|
72
|
-
expect(out).toEqual({ ok: true, name: "get_weather" });
|
|
73
|
-
expect(client.calls).toHaveLength(1);
|
|
74
|
-
expect(client.calls[0]).toMatchObject({
|
|
75
|
-
name: "get_weather",
|
|
76
|
-
tenantId: "org_a",
|
|
77
|
-
args: { q: "NYC" },
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("gates by plan — low-plan tenant gets fewer tools", async () => {
|
|
82
|
-
const catalog = fakeCatalog({
|
|
83
|
-
org_a: {
|
|
84
|
-
free: [{ server: "s", definition: def("basic") }],
|
|
85
|
-
pro: [
|
|
86
|
-
{ server: "s", definition: def("basic") },
|
|
87
|
-
{ server: "s", definition: def("premium") },
|
|
88
|
-
],
|
|
89
|
-
},
|
|
90
|
-
});
|
|
91
|
-
const client = fakeClient();
|
|
92
|
-
|
|
93
|
-
const freeReg = new ToolRegistry();
|
|
94
|
-
const free = await activateMcpTools(freeReg, catalog, client, {
|
|
95
|
-
tenantId: "org_a",
|
|
96
|
-
plan: "free",
|
|
97
|
-
});
|
|
98
|
-
expect(free.registered).toEqual(["basic"]);
|
|
99
|
-
|
|
100
|
-
const proReg = new ToolRegistry();
|
|
101
|
-
const pro = await activateMcpTools(proReg, catalog, client, { tenantId: "org_a", plan: "pro" });
|
|
102
|
-
expect(pro.registered).toEqual(["basic", "premium"]);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it("skips a duplicate-name tool instead of throwing", async () => {
|
|
106
|
-
const registry = new ToolRegistry();
|
|
107
|
-
registry.register(def("get_weather"), async () => ({ native: true }));
|
|
108
|
-
|
|
109
|
-
const catalog = fakeCatalog({
|
|
110
|
-
org_a: {
|
|
111
|
-
free: [
|
|
112
|
-
{ server: "weather", definition: def("get_weather") },
|
|
113
|
-
{ server: "weather", definition: def("get_forecast") },
|
|
114
|
-
],
|
|
115
|
-
pro: [],
|
|
116
|
-
},
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
const res = await activateMcpTools(registry, catalog, fakeClient(), { tenantId: "org_a" });
|
|
120
|
-
|
|
121
|
-
expect(res.registered).toEqual(["get_forecast"]);
|
|
122
|
-
expect(res.skipped).toEqual(["get_weather"]);
|
|
123
|
-
expect(registry.list()).toHaveLength(2);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("fails closed on empty tenantId before touching the catalog", async () => {
|
|
127
|
-
const catalog = fakeCatalog({});
|
|
128
|
-
const spy = vi.spyOn(catalog, "listTools");
|
|
129
|
-
await expect(
|
|
130
|
-
activateMcpTools(new ToolRegistry(), catalog, fakeClient(), { tenantId: "" }),
|
|
131
|
-
).rejects.toThrow(/tenantId/i);
|
|
132
|
-
expect(spy).not.toHaveBeenCalled();
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("fails closed on whitespace-only tenantId", async () => {
|
|
136
|
-
await expect(
|
|
137
|
-
activateMcpTools(new ToolRegistry(), fakeCatalog({}), fakeClient(), { tenantId: " " }),
|
|
138
|
-
).rejects.toThrow(/tenantId/i);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("isolates tenants — tenant A never sees tenant B's catalog", async () => {
|
|
142
|
-
const catalog = fakeCatalog({
|
|
143
|
-
org_a: { free: [{ server: "s", definition: def("a_tool") }], pro: [] },
|
|
144
|
-
org_b: { free: [{ server: "s", definition: def("b_tool") }], pro: [] },
|
|
145
|
-
});
|
|
146
|
-
const client = fakeClient();
|
|
147
|
-
|
|
148
|
-
const regA = new ToolRegistry();
|
|
149
|
-
const a = await activateMcpTools(regA, catalog, client, { tenantId: "org_a" });
|
|
150
|
-
expect(a.registered).toEqual(["a_tool"]);
|
|
151
|
-
expect(regA.list().map((r) => r.definition.name)).not.toContain("b_tool");
|
|
152
|
-
|
|
153
|
-
await regA.dispatch("a_tool", { q: "x" }, { tenantId: "org_a", threadId: "t1" });
|
|
154
|
-
expect(client.calls.every((c) => c.tenantId === "org_a")).toBe(true);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it("returns mcp origin so adapted tools are provenance-tagged", async () => {
|
|
158
|
-
const registry = new ToolRegistry();
|
|
159
|
-
const catalog = fakeCatalog({
|
|
160
|
-
org_a: { free: [{ server: "weather", definition: def("get_weather") }], pro: [] },
|
|
161
|
-
});
|
|
162
|
-
await activateMcpTools(registry, catalog, fakeClient(), { tenantId: "org_a" });
|
|
163
|
-
expect(registry.list()[0]?.origin).toEqual({ kind: "mcp", server: "weather" });
|
|
164
|
-
});
|
|
165
|
-
});
|
package/src/mcp-bridge.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MCP activation bridge (WRAP — capability #9, activation seam).
|
|
3
|
-
*
|
|
4
|
-
* Concrete bridge that registers external MCP-server tools into the runtime's
|
|
5
|
-
* uniform tool model. It does NOT import `@nebutra/mcp` (WIP / do-not-import):
|
|
6
|
-
* instead it defines minimal injectable ports so callers wire `@nebutra/mcp`
|
|
7
|
-
* (`serverRegistry` + `mcpClient`) without this package taking a hard dep.
|
|
8
|
-
*
|
|
9
|
-
* Tenant-scoped by construction: the catalog port is always queried with the
|
|
10
|
-
* caller's `{ tenantId, plan }`, so a tenant only ever sees its own MCP
|
|
11
|
-
* servers/tools. Fail-closed on missing tenant.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { z } from "zod";
|
|
15
|
-
|
|
16
|
-
import { adaptMcpTool, type McpClientLike, type ToolDefinition, type ToolRegistry } from "./tools";
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Port over an MCP server catalog (satisfied by `@nebutra/mcp`'s
|
|
20
|
-
* `serverRegistry` + plan middleware). Returns only the tools visible to the
|
|
21
|
-
* given tenant/plan — visibility/plan-gating is the port's responsibility.
|
|
22
|
-
*/
|
|
23
|
-
export interface McpServerCatalogPort {
|
|
24
|
-
listTools(ctx: {
|
|
25
|
-
tenantId: string;
|
|
26
|
-
plan?: string;
|
|
27
|
-
}): Promise<{ server: string; definition: ToolDefinition }[]>;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const ctxSchema = z.object({
|
|
31
|
-
tenantId: z.string().trim().min(1, "tenantId is required (fail-closed)"),
|
|
32
|
-
plan: z.string().min(1).optional(),
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
export interface ActivateMcpToolsResult {
|
|
36
|
-
/** Tool names newly registered into the registry, in catalog order. */
|
|
37
|
-
readonly registered: readonly string[];
|
|
38
|
-
/** Tool names skipped because the name was already registered. */
|
|
39
|
-
readonly skipped: readonly string[];
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* List the tenant/plan-visible MCP tools, adapt each via {@link adaptMcpTool},
|
|
44
|
-
* and register them into the {@link ToolRegistry}. A tool whose name is already
|
|
45
|
-
* registered is skipped (reported, never thrown). Empty/blank tenantId fails
|
|
46
|
-
* closed before any catalog call.
|
|
47
|
-
*/
|
|
48
|
-
export async function activateMcpTools(
|
|
49
|
-
registry: ToolRegistry,
|
|
50
|
-
catalog: McpServerCatalogPort,
|
|
51
|
-
client: McpClientLike,
|
|
52
|
-
ctx: { tenantId: string; plan?: string },
|
|
53
|
-
): Promise<ActivateMcpToolsResult> {
|
|
54
|
-
const scope = ctxSchema.parse(ctx);
|
|
55
|
-
|
|
56
|
-
const entries = await catalog.listTools(
|
|
57
|
-
scope.plan === undefined
|
|
58
|
-
? { tenantId: scope.tenantId }
|
|
59
|
-
: { tenantId: scope.tenantId, plan: scope.plan },
|
|
60
|
-
);
|
|
61
|
-
|
|
62
|
-
const registered: string[] = [];
|
|
63
|
-
const skipped: string[] = [];
|
|
64
|
-
|
|
65
|
-
for (const { server, definition } of entries) {
|
|
66
|
-
if (registry.list().some((r) => r.definition.name === definition.name)) {
|
|
67
|
-
skipped.push(definition.name);
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
const adapted = adaptMcpTool(server, definition, client);
|
|
71
|
-
registry.register(adapted.definition, adapted.handler, adapted.origin);
|
|
72
|
-
registered.push(definition.name);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return { registered, skipped };
|
|
76
|
-
}
|