@runravel/ravel 0.1.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/CHANGELOG.md +34 -0
- package/LICENSE +202 -0
- package/README.md +68 -0
- package/bin/ravel.mjs +6 -0
- package/examples/acme/agent.md +14 -0
- package/examples/acme/growth/agent.md +19 -0
- package/examples/acme/growth/copywriter/agent.md +13 -0
- package/examples/acme/growth/copywriter/tools.json +11 -0
- package/examples/acme/growth/researcher/agent.md +11 -0
- package/examples/acme/processes/prospect-outreach.process.md +24 -0
- package/examples/harbor/agent.md +31 -0
- package/examples/harbor/processes/new-client-quote.process.md +31 -0
- package/examples/harbor/processes/resolve-ticket-batch.process.md +33 -0
- package/examples/harbor/sales/agent.md +29 -0
- package/examples/harbor/sales/sdr/agent.md +24 -0
- package/examples/harbor/sales/solutions/agent.md +29 -0
- package/examples/harbor/sales/solutions/tools.json +16 -0
- package/examples/harbor/support/agent.md +31 -0
- package/examples/harbor/support/kb-writer/agent.md +25 -0
- package/examples/harbor/support/kb-writer/tools.json +10 -0
- package/examples/harbor/support/qa-reviewer/agent.md +23 -0
- package/examples/harbor/support/tools.json +16 -0
- package/examples/harbor/support/triage/agent.md +24 -0
- package/examples/harbor/support/triage/tools.json +10 -0
- package/examples/plugin-demo/agent.md +15 -0
- package/examples/plugin-demo/processes/jot.process.md +21 -0
- package/examples/plugin-demo/scribe/agent.md +15 -0
- package/examples/plugin-demo/scribe/plugin.ts +53 -0
- package/examples/plugin-demo/scribe/tools.json +9 -0
- package/package.json +65 -0
- package/src/cli/main.ts +428 -0
- package/src/control-plane/registry.ts +294 -0
- package/src/control-plane/watcher.ts +132 -0
- package/src/domain/ids.ts +17 -0
- package/src/domain/pricing.ts +51 -0
- package/src/domain/types.ts +168 -0
- package/src/index.ts +35 -0
- package/src/memory/genericTools.ts +276 -0
- package/src/memory/kv.ts +52 -0
- package/src/memory/store.ts +76 -0
- package/src/messaging/bus.ts +143 -0
- package/src/messaging/inbox.ts +119 -0
- package/src/orchestrator/orchestrator.ts +270 -0
- package/src/orchestrator/planner.ts +218 -0
- package/src/platform/app.ts +287 -0
- package/src/plugins/loader.ts +86 -0
- package/src/plugins/server.ts +41 -0
- package/src/plugins/types.ts +96 -0
- package/src/runtime/agent.ts +488 -0
- package/src/runtime/engine.ts +84 -0
- package/src/runtime/fakeEngine.ts +75 -0
- package/src/runtime/lifecycle.ts +85 -0
- package/src/runtime/officeActions.ts +58 -0
- package/src/runtime/officeTools.ts +42 -0
- package/src/runtime/sdkEngine.ts +213 -0
- package/src/schemas/agent.ts +47 -0
- package/src/schemas/common.ts +52 -0
- package/src/schemas/frontmatter.ts +36 -0
- package/src/schemas/process.ts +55 -0
- package/src/schemas/tools.ts +83 -0
- package/src/secrets/store.ts +131 -0
- package/src/service/scheduler.ts +377 -0
- package/src/service/server.ts +554 -0
- package/src/trust/approval.ts +180 -0
- package/src/trust/audit.ts +195 -0
- package/src/trust/budget.ts +64 -0
- package/src/trust/emittingAudit.ts +32 -0
- package/src/trust/executor.ts +73 -0
- package/src/trust/killswitch.ts +73 -0
- package/src/trust/observability.ts +97 -0
- package/src/trust/proposals.ts +114 -0
- package/ui/dist/assets/index-C6CxDaPS.js +44 -0
- package/ui/dist/assets/index-CD-lhs0Z.css +1 -0
- package/ui/dist/index.html +13 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import type { ChokidarOptions } from "chokidar";
|
|
4
|
+
import { RegistryWatcher } from "../control-plane/watcher.js";
|
|
5
|
+
import type { RegistrySnapshot, Diagnostic } from "../control-plane/registry.js";
|
|
6
|
+
import { Lifecycle } from "../runtime/lifecycle.js";
|
|
7
|
+
import type { AgentEngine } from "../runtime/engine.js";
|
|
8
|
+
import { Orchestrator, type ProcessRunResult } from "../orchestrator/orchestrator.js";
|
|
9
|
+
import { EnginePlanner } from "../orchestrator/planner.js";
|
|
10
|
+
import { MessageBus } from "../messaging/bus.js";
|
|
11
|
+
import { MemoryStore } from "../memory/store.js";
|
|
12
|
+
import { JsonlAudit, LoggingAudit, type AuditSink } from "../trust/audit.js";
|
|
13
|
+
import { ApprovalBroker, type ApprovalMode } from "../trust/approval.js";
|
|
14
|
+
import { ProposalStore } from "../trust/proposals.js";
|
|
15
|
+
import { ActionExecutor } from "../trust/executor.js";
|
|
16
|
+
import { KillSwitch } from "../trust/killswitch.js";
|
|
17
|
+
import { Observer, type DashboardSnapshot } from "../trust/observability.js";
|
|
18
|
+
import { OFFICE_TOOL_NAMES, runOfficeAction } from "../runtime/officeActions.js";
|
|
19
|
+
import { PluginRegistry } from "../plugins/loader.js";
|
|
20
|
+
import { SecretStore } from "../secrets/store.js";
|
|
21
|
+
import type { PermissionDecision, ApprovalRequest, Proposal } from "../domain/types.js";
|
|
22
|
+
|
|
23
|
+
export interface AppOptions {
|
|
24
|
+
/** Org root folder (must contain the top-level agent.md). */
|
|
25
|
+
root: string;
|
|
26
|
+
/** LLM backend. Use SdkEngine in production, a fake in tests. */
|
|
27
|
+
engine: AgentEngine;
|
|
28
|
+
/** Inject an audit sink; defaults to a JSONL file under the runtime dir. */
|
|
29
|
+
audit?: AuditSink;
|
|
30
|
+
/** Dry-run: agents produce intended actions but execute no tools. */
|
|
31
|
+
dryRun?: boolean;
|
|
32
|
+
/** Tee every audit event to this sink (e.g. stderr) for live verbose logging. */
|
|
33
|
+
verbose?: (line: string) => void;
|
|
34
|
+
/** Where runtime state (working dirs, inboxes, memory, audit) lives. */
|
|
35
|
+
runtimeDir?: string;
|
|
36
|
+
/** Persist inboxes to disk under the runtime dir. */
|
|
37
|
+
persistMessages?: boolean;
|
|
38
|
+
/** Approval mode: "deferred" (default — async proposals) or "sync" (blocking, interactive). */
|
|
39
|
+
approvals?: ApprovalMode;
|
|
40
|
+
watchOptions?: Partial<ChokidarOptions>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The Ravel runtime. Wires the control plane (watcher → lifecycle),
|
|
45
|
+
* execution (orchestrator + agent runtimes), messaging (bus + inboxes),
|
|
46
|
+
* memory, and the trust layer (audit, approvals, kill switch, observability)
|
|
47
|
+
* into one object the owner drives.
|
|
48
|
+
*/
|
|
49
|
+
export class App {
|
|
50
|
+
readonly audit: AuditSink;
|
|
51
|
+
readonly approvals: ApprovalBroker;
|
|
52
|
+
readonly proposals: ProposalStore;
|
|
53
|
+
readonly executor: ActionExecutor;
|
|
54
|
+
readonly killSwitch: KillSwitch;
|
|
55
|
+
readonly lifecycle: Lifecycle;
|
|
56
|
+
readonly bus: MessageBus;
|
|
57
|
+
readonly memory: MemoryStore;
|
|
58
|
+
readonly orchestrator: Orchestrator;
|
|
59
|
+
readonly observer: Observer;
|
|
60
|
+
/** Loaded team plugins (in-process code tools scoped per team). */
|
|
61
|
+
readonly plugins: PluginRegistry;
|
|
62
|
+
/** Per-node credential resolver (each agent's `.env` chain). */
|
|
63
|
+
readonly secrets: SecretStore;
|
|
64
|
+
/** Plugin executor actions already registered (idempotent across snapshot applies). */
|
|
65
|
+
private readonly registeredPluginActions = new Set<string>();
|
|
66
|
+
/** Where runtime state (working dirs, runs, proposals, audit) lives. */
|
|
67
|
+
readonly runtimeDir: string;
|
|
68
|
+
/** Org root folder. */
|
|
69
|
+
readonly root: string;
|
|
70
|
+
|
|
71
|
+
private readonly watcher: RegistryWatcher;
|
|
72
|
+
private snapshot: RegistrySnapshot | null = null;
|
|
73
|
+
private applyChain: Promise<void> = Promise.resolve();
|
|
74
|
+
|
|
75
|
+
constructor(private readonly opts: AppOptions) {
|
|
76
|
+
const runtimeDir = opts.runtimeDir ?? path.join(opts.root, ".ravel");
|
|
77
|
+
this.runtimeDir = runtimeDir;
|
|
78
|
+
this.root = opts.root;
|
|
79
|
+
const baseAudit = opts.audit ?? new JsonlAudit(path.join(runtimeDir, "audit.jsonl"));
|
|
80
|
+
this.audit = opts.verbose ? new LoggingAudit(baseAudit, opts.verbose) : baseAudit;
|
|
81
|
+
this.proposals = new ProposalStore({ filePath: path.join(runtimeDir, "proposals.json") });
|
|
82
|
+
this.approvals = new ApprovalBroker(this.audit, {
|
|
83
|
+
mode: opts.approvals ?? "deferred",
|
|
84
|
+
proposals: this.proposals,
|
|
85
|
+
...(opts.dryRun ? { dryRun: true } : {}),
|
|
86
|
+
});
|
|
87
|
+
// The executor performs approved proposed actions deterministically.
|
|
88
|
+
this.executor = new ActionExecutor(this.audit);
|
|
89
|
+
for (const action of OFFICE_TOOL_NAMES) {
|
|
90
|
+
this.executor.register(action, (input, ctx) => runOfficeAction(action, input, ctx));
|
|
91
|
+
}
|
|
92
|
+
this.killSwitch = new KillSwitch();
|
|
93
|
+
this.bus = new MessageBus({
|
|
94
|
+
audit: this.audit,
|
|
95
|
+
...(opts.persistMessages ? { messagesDir: path.join(runtimeDir, "messages") } : {}),
|
|
96
|
+
});
|
|
97
|
+
this.memory = new MemoryStore(path.join(runtimeDir, "memory"));
|
|
98
|
+
this.plugins = new PluginRegistry(this.root, this.audit);
|
|
99
|
+
this.secrets = new SecretStore(this.root);
|
|
100
|
+
this.lifecycle = new Lifecycle({
|
|
101
|
+
engine: opts.engine,
|
|
102
|
+
audit: this.audit,
|
|
103
|
+
approvals: this.approvals,
|
|
104
|
+
killSwitch: this.killSwitch,
|
|
105
|
+
workdirRoot: path.join(runtimeDir, "agents"),
|
|
106
|
+
memory: this.memory,
|
|
107
|
+
plugins: this.plugins,
|
|
108
|
+
secrets: this.secrets,
|
|
109
|
+
});
|
|
110
|
+
this.orchestrator = new Orchestrator({
|
|
111
|
+
lifecycle: this.lifecycle,
|
|
112
|
+
planner: new EnginePlanner((id) => this.lifecycle.get(id)),
|
|
113
|
+
audit: this.audit,
|
|
114
|
+
workspaceRoot: path.join(runtimeDir, "runs"),
|
|
115
|
+
});
|
|
116
|
+
this.observer = new Observer(this.audit, this.lifecycle, this.proposals, () => this.bus.deadLetters.length);
|
|
117
|
+
|
|
118
|
+
this.watcher = new RegistryWatcher(opts.root, {
|
|
119
|
+
...(opts.watchOptions ? { watchOptions: opts.watchOptions } : {}),
|
|
120
|
+
});
|
|
121
|
+
this.watcher.on("snapshot", (snap) => this.enqueueApply(snap));
|
|
122
|
+
this.watcher.on("invalid", (diags: Diagnostic[]) => {
|
|
123
|
+
void this.audit.append("registry.invalid", {
|
|
124
|
+
data: { diagnostics: diags.map((d) => `${d.where}: ${d.message}`) },
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* One-time state-dir migration: the runtime dir was `.businessos` before 0.1.
|
|
131
|
+
* Fires only when this app uses the DEFAULT state location — a custom
|
|
132
|
+
* runtimeDir (e.g. `--state-dir`) is the caller's business. Must run before
|
|
133
|
+
* any store touches disk; returns whether a rename happened so the audit
|
|
134
|
+
* event can be appended after the log is loaded (appending first would
|
|
135
|
+
* double-count it on rehydrate).
|
|
136
|
+
*/
|
|
137
|
+
private async migrateLegacyStateDir(): Promise<boolean> {
|
|
138
|
+
if (path.resolve(this.runtimeDir) !== path.resolve(this.root, ".ravel")) return false;
|
|
139
|
+
const legacy = path.join(this.root, ".businessos");
|
|
140
|
+
const exists = (p: string) => fs.stat(p).then(() => true, () => false);
|
|
141
|
+
if (!(await exists(legacy)) || (await exists(this.runtimeDir))) return false;
|
|
142
|
+
await fs.rename(legacy, this.runtimeDir);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Compile the org, spawn agents, and begin watching for edits. */
|
|
147
|
+
async start(): Promise<void> {
|
|
148
|
+
const migrated = await this.migrateLegacyStateDir();
|
|
149
|
+
// Rehydrate durable state so runs, chats, proposals, and spend survive restarts.
|
|
150
|
+
await this.audit.load?.();
|
|
151
|
+
await this.proposals.load();
|
|
152
|
+
if (migrated) {
|
|
153
|
+
await this.audit.append("state.migrated", { data: { from: ".businessos", to: ".ravel" } });
|
|
154
|
+
}
|
|
155
|
+
const initial = await this.watcher.start();
|
|
156
|
+
if (!initial.ok || !initial.snapshot) {
|
|
157
|
+
const detail = initial.diagnostics.map((d) => `${d.where}: ${d.message}`).join("; ");
|
|
158
|
+
throw new Error(`org failed to compile: ${detail}`);
|
|
159
|
+
}
|
|
160
|
+
// watcher.start() already emitted this snapshot → an apply is queued on
|
|
161
|
+
// applyChain. Enqueue-and-await (rather than a second, concurrent direct
|
|
162
|
+
// apply) so the initial apply — plugin load + action registration — is fully
|
|
163
|
+
// settled before start() returns, with no race on the two applies.
|
|
164
|
+
this.enqueueApply(initial.snapshot);
|
|
165
|
+
await this.applyChain;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async stop(): Promise<void> {
|
|
169
|
+
await this.watcher.stop();
|
|
170
|
+
await this.applyChain; // let any in-flight reconciliation finish
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private enqueueApply(snap: RegistrySnapshot): void {
|
|
174
|
+
this.applyChain = this.applyChain.then(() => this.apply(snap)).catch(() => undefined);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private async apply(snap: RegistrySnapshot): Promise<void> {
|
|
178
|
+
this.snapshot = snap;
|
|
179
|
+
this.bus.updateTopology(snap);
|
|
180
|
+
// Load any new team plugins, then register their gated executor actions
|
|
181
|
+
// (before agents run, so an approved proposal has a handler waiting).
|
|
182
|
+
await this.plugins.syncFromSnapshot(snap);
|
|
183
|
+
await this.registerPluginActions();
|
|
184
|
+
await this.lifecycle.applySnapshot(snap);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Register each loaded plugin's actions on the executor, once per action name. */
|
|
188
|
+
private async registerPluginActions(): Promise<void> {
|
|
189
|
+
for (const { nodeId, plugin } of this.plugins.all()) {
|
|
190
|
+
for (const action of plugin.actions ?? []) {
|
|
191
|
+
if (this.registeredPluginActions.has(action.name)) continue;
|
|
192
|
+
if (this.executor.has(action.name)) {
|
|
193
|
+
await this.audit.append("plugin.action_conflict", {
|
|
194
|
+
nodeId,
|
|
195
|
+
data: { action: action.name, plugin: plugin.name },
|
|
196
|
+
});
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const memory = this.memory;
|
|
200
|
+
this.executor.register(action.name, async (input, ctx) =>
|
|
201
|
+
action.handler(input, {
|
|
202
|
+
...ctx,
|
|
203
|
+
memory,
|
|
204
|
+
...(ctx.managerNodeId !== undefined ? { teamScope: { kind: "team" as const, managerNodeId: ctx.managerNodeId } } : {}),
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
this.registeredPluginActions.add(action.name);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
currentSnapshot(): RegistrySnapshot | null {
|
|
213
|
+
return this.snapshot;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Run a named process (a playbook). Optionally pass run inputs (e.g. a
|
|
218
|
+
* prospect name, target languages) and source files (absolute host paths)
|
|
219
|
+
* that get staged into each dispatched worker's working directory.
|
|
220
|
+
*/
|
|
221
|
+
async runProcess(
|
|
222
|
+
name: string,
|
|
223
|
+
run: { inputs?: Record<string, unknown>; files?: string[]; runId?: string } = {},
|
|
224
|
+
): Promise<ProcessRunResult> {
|
|
225
|
+
if (!this.snapshot) throw new Error("platform not started");
|
|
226
|
+
const proc = this.snapshot.processes.find((p) => p.spec.name === name);
|
|
227
|
+
if (!proc) {
|
|
228
|
+
const available = this.snapshot.processes.map((p) => p.spec.name).join(", ") || "(none)";
|
|
229
|
+
throw new Error(`no process named "${name}". Available: ${available}`);
|
|
230
|
+
}
|
|
231
|
+
return this.orchestrator.runProcess(proc, this.snapshot, run);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Chat directly with one agent by node id. */
|
|
235
|
+
async chat(nodeId: string, message: string): Promise<string> {
|
|
236
|
+
const agent = this.lifecycle.get(nodeId);
|
|
237
|
+
if (!agent) throw new Error(`no agent at node "${nodeId}"`);
|
|
238
|
+
return agent.chat(message);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Sync-mode blocking approvals (interactive CLI). Empty in deferred mode. */
|
|
242
|
+
pendingApprovals(): ApprovalRequest[] {
|
|
243
|
+
return this.approvals.pending();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async resolveApproval(id: string, decision: PermissionDecision): Promise<boolean> {
|
|
247
|
+
return this.approvals.resolve(id, decision);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Deferred-mode proposals awaiting an async human decision. */
|
|
251
|
+
pendingProposals(): Proposal[] {
|
|
252
|
+
return this.proposals.pending();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Resolve a deferred proposal. On approve, the executor performs the action
|
|
257
|
+
* (deterministically — no model call) and the proposal is marked executed/failed;
|
|
258
|
+
* on reject, it's marked rejected and nothing runs.
|
|
259
|
+
*/
|
|
260
|
+
async resolveProposal(id: string, decision: "approve" | "reject"): Promise<Proposal | null> {
|
|
261
|
+
const proposal = this.proposals.get(id);
|
|
262
|
+
if (!proposal || proposal.status !== "pending") return null;
|
|
263
|
+
|
|
264
|
+
if (decision === "reject") {
|
|
265
|
+
return this.proposals.setStatus(id, "rejected");
|
|
266
|
+
}
|
|
267
|
+
await this.proposals.setStatus(id, "approved");
|
|
268
|
+
const result = await this.executor.execute(proposal);
|
|
269
|
+
return this.proposals.setStatus(id, result.ok ? "executed" : "failed", {
|
|
270
|
+
...(result.result !== undefined ? { result: result.result } : {}),
|
|
271
|
+
...(result.error !== undefined ? { error: result.error } : {}),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Halt a scope: an agent id, a subtree prefix, or "*" for the whole org. */
|
|
276
|
+
kill(scope: string): number {
|
|
277
|
+
return this.killSwitch.kill(scope);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
clearKill(scope: string): void {
|
|
281
|
+
this.killSwitch.clear(scope);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
dashboard(): DashboardSnapshot {
|
|
285
|
+
return this.observer.snapshot();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import type { RegistrySnapshot } from "../control-plane/registry.js";
|
|
4
|
+
import type { AuditSink } from "../trust/audit.js";
|
|
5
|
+
import { isPluginDefinition, type PluginDefinition } from "./types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Loads team plugins (a `plugin.ts` default-exporting `definePlugin({...})`) and
|
|
9
|
+
* scopes them by node. Plugins run **in-process, unsandboxed** — they are the
|
|
10
|
+
* operator's own folders, same trust as `agent.md`.
|
|
11
|
+
*
|
|
12
|
+
* v1 cache rule: a node's plugin is imported once and cached. Editing
|
|
13
|
+
* `agent.md`/`tools.json`/`processes` still hot-reloads, but changing plugin CODE
|
|
14
|
+
* needs a `serve` restart (the JS module cache won't re-execute a re-import).
|
|
15
|
+
* Newly-ADDED plugin nodes are picked up on the next snapshot.
|
|
16
|
+
*
|
|
17
|
+
* A plugin that throws on import or fails validation is audited (`plugin.load_failed`)
|
|
18
|
+
* and skipped — its team's agents still run, just without those tools.
|
|
19
|
+
*/
|
|
20
|
+
export class PluginRegistry {
|
|
21
|
+
/** Plugins keyed by the node that DECLARES them (owning node only). */
|
|
22
|
+
private readonly byNode = new Map<string, PluginDefinition>();
|
|
23
|
+
/** node id → parent id, from the latest snapshot (for plugin inheritance). */
|
|
24
|
+
private readonly parent = new Map<string, string | null>();
|
|
25
|
+
/** Node ids we've already attempted to import (load-once; code change → restart). */
|
|
26
|
+
private readonly attempted = new Set<string>();
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
private readonly root: string,
|
|
30
|
+
private readonly audit: AuditSink,
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The plugin governing a node: its own if declared, else the nearest ancestor's
|
|
35
|
+
* (a team-root `plugin.ts` serves all descendant agents). Grants in each agent's
|
|
36
|
+
* `tools.json` still decide which of the plugin's tools that agent may call.
|
|
37
|
+
*/
|
|
38
|
+
forNode(nodeId: string): PluginDefinition | undefined {
|
|
39
|
+
let cur: string | undefined = nodeId;
|
|
40
|
+
while (cur !== undefined) {
|
|
41
|
+
const own = this.byNode.get(cur);
|
|
42
|
+
if (own) return own;
|
|
43
|
+
cur = this.parent.get(cur) ?? undefined;
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Owning nodes only (one entry per declared plugin) — so executor actions are
|
|
50
|
+
* registered once, never duplicated across inheriting descendants.
|
|
51
|
+
*/
|
|
52
|
+
all(): Array<{ nodeId: string; plugin: PluginDefinition }> {
|
|
53
|
+
return [...this.byNode.entries()].map(([nodeId, plugin]) => ({ nodeId, plugin }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Import any not-yet-loaded plugins declared in the snapshot. Idempotent: each
|
|
58
|
+
* node is attempted at most once for the life of the process. Also refreshes the
|
|
59
|
+
* parent map so inheritance resolves against the current tree.
|
|
60
|
+
*/
|
|
61
|
+
async syncFromSnapshot(snapshot: RegistrySnapshot): Promise<void> {
|
|
62
|
+
for (const node of snapshot.nodes.values()) this.parent.set(node.id, node.parentId);
|
|
63
|
+
for (const node of snapshot.nodes.values()) {
|
|
64
|
+
if (!node.pluginPath || this.attempted.has(node.id)) continue;
|
|
65
|
+
this.attempted.add(node.id);
|
|
66
|
+
const abs = path.resolve(this.root, node.pluginPath);
|
|
67
|
+
try {
|
|
68
|
+
const mod = (await import(pathToFileURL(abs).href)) as { default?: unknown };
|
|
69
|
+
const def = mod.default;
|
|
70
|
+
if (!isPluginDefinition(def)) {
|
|
71
|
+
throw new Error("plugin default export is not a valid definePlugin({...}) value");
|
|
72
|
+
}
|
|
73
|
+
this.byNode.set(node.id, def);
|
|
74
|
+
await this.audit.append("plugin.loaded", {
|
|
75
|
+
nodeId: node.id,
|
|
76
|
+
data: { name: def.name, tools: def.tools?.length ?? 0, actions: def.actions?.length ?? 0 },
|
|
77
|
+
});
|
|
78
|
+
} catch (err) {
|
|
79
|
+
await this.audit.append("plugin.load_failed", {
|
|
80
|
+
nodeId: node.id,
|
|
81
|
+
data: { path: node.pluginPath, error: err instanceof Error ? err.message : String(err) },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
3
|
+
import { json } from "../memory/kv.js";
|
|
4
|
+
import type { MemoryStore } from "../memory/store.js";
|
|
5
|
+
import type { PluginTool, PluginToolCtx } from "./types.js";
|
|
6
|
+
|
|
7
|
+
function isContent(x: unknown): x is { content: unknown[] } {
|
|
8
|
+
return !!x && typeof x === "object" && Array.isArray((x as { content?: unknown }).content);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build the in-process `plugin` MCP server for a team's plugin tools that the
|
|
13
|
+
* agent was granted. Tool handlers get a `PluginToolCtx` (memory, team scope,
|
|
14
|
+
* cwd, declared env). A handler may return an MCP content object or a plain value
|
|
15
|
+
* (JSON-wrapped for the model).
|
|
16
|
+
*/
|
|
17
|
+
export function buildPluginServer(
|
|
18
|
+
grantedNames: string[],
|
|
19
|
+
pluginTools: PluginTool[],
|
|
20
|
+
ctx: { nodeId: string; managerNodeId: string; memory: MemoryStore; cwd: string; env: Record<string, string> },
|
|
21
|
+
): McpServerConfig | null {
|
|
22
|
+
const granted = new Set(grantedNames);
|
|
23
|
+
const toolCtx: PluginToolCtx = {
|
|
24
|
+
memory: ctx.memory,
|
|
25
|
+
nodeId: ctx.nodeId,
|
|
26
|
+
managerNodeId: ctx.managerNodeId,
|
|
27
|
+
teamScope: { kind: "team", managerNodeId: ctx.managerNodeId },
|
|
28
|
+
cwd: ctx.cwd,
|
|
29
|
+
env: ctx.env,
|
|
30
|
+
};
|
|
31
|
+
const tools = pluginTools
|
|
32
|
+
.filter((t) => granted.has(t.name))
|
|
33
|
+
.map((t) =>
|
|
34
|
+
tool(t.name, t.description, t.schema, async (args: Record<string, unknown>) => {
|
|
35
|
+
const out = await t.handler(args, toolCtx);
|
|
36
|
+
return isContent(out) ? out : json(out);
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
if (tools.length === 0) return null;
|
|
40
|
+
return createSdkMcpServer({ name: "plugin", version: "1.0.0", tools });
|
|
41
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ZodRawShape } from "zod";
|
|
2
|
+
import type { MemoryStore, MemoryScope } from "../memory/store.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Team plugin contract. A team folder ships a `plugin.ts` whose **default export**
|
|
6
|
+
* is `definePlugin({...})`. The platform loads it, scopes its tools to that team,
|
|
7
|
+
* and registers its gated actions on the executor — without any of the team's
|
|
8
|
+
* domain code living in `src/`.
|
|
9
|
+
*
|
|
10
|
+
* Two kinds of capability:
|
|
11
|
+
* - `tools` — in-process MCP tools the agent can call during reasoning. Their
|
|
12
|
+
* permission policy comes from the agent's `tools.json` (auto/ask/deny),
|
|
13
|
+
* exactly like built-in/office tools.
|
|
14
|
+
* - `actions` — deterministic handlers run by the executor AFTER a human approves
|
|
15
|
+
* a proposal. A *gated* tool is one that appears in BOTH lists with
|
|
16
|
+
* the same name and `policy:"ask"` in tools.json: the tool call is
|
|
17
|
+
* deferred to a proposal, and the action performs the real write on
|
|
18
|
+
* approval. (This is exactly how `promote_to_watchlist` works.)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Context handed to a plugin tool handler (called during agent reasoning). */
|
|
22
|
+
export interface PluginToolCtx {
|
|
23
|
+
memory: MemoryStore;
|
|
24
|
+
nodeId: string;
|
|
25
|
+
/** The team-memory scope key (manager + direct reports share it). */
|
|
26
|
+
managerNodeId: string;
|
|
27
|
+
/** Convenience: the resolved team `MemoryScope` for durable team state. */
|
|
28
|
+
teamScope: MemoryScope;
|
|
29
|
+
/** The agent's working directory for this call. */
|
|
30
|
+
cwd: string;
|
|
31
|
+
/** Only the env vars the plugin declared in `env`, resolved from the host. */
|
|
32
|
+
env: Record<string, string>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Context handed to a plugin executor action (run post-approval). */
|
|
36
|
+
export interface PluginActionCtx {
|
|
37
|
+
cwd: string;
|
|
38
|
+
nodeId: string;
|
|
39
|
+
runId?: string;
|
|
40
|
+
managerNodeId?: string;
|
|
41
|
+
/** Durable store, injected by the platform so a gated action can persist its write. */
|
|
42
|
+
memory: MemoryStore;
|
|
43
|
+
/** The team `MemoryScope`, when the approved proposal carried a team (`managerNodeId`). */
|
|
44
|
+
teamScope?: MemoryScope;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface PluginActionResult {
|
|
48
|
+
ok: boolean;
|
|
49
|
+
result?: unknown;
|
|
50
|
+
error?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface PluginTool {
|
|
54
|
+
name: string;
|
|
55
|
+
description: string;
|
|
56
|
+
/** A Zod raw shape (e.g. `{ url: z.string() }`) — the tool's input schema. */
|
|
57
|
+
schema: ZodRawShape;
|
|
58
|
+
/** Returns a plain value (JSON-wrapped for the model) or an MCP content object. */
|
|
59
|
+
handler: (input: Record<string, unknown>, ctx: PluginToolCtx) => Promise<unknown>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface PluginAction {
|
|
63
|
+
name: string;
|
|
64
|
+
handler: (input: unknown, ctx: PluginActionCtx) => Promise<PluginActionResult>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface PluginDefinition {
|
|
68
|
+
name: string;
|
|
69
|
+
version?: string;
|
|
70
|
+
/** Host env var names this plugin needs (forwarded into `PluginToolCtx.env`). */
|
|
71
|
+
env?: string[];
|
|
72
|
+
tools?: PluginTool[];
|
|
73
|
+
actions?: PluginAction[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Identity helper a team's `plugin.ts` calls as its default export. */
|
|
77
|
+
export function definePlugin(def: PluginDefinition): PluginDefinition {
|
|
78
|
+
return def;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Structural validation of a dynamically-imported plugin default export. */
|
|
82
|
+
export function isPluginDefinition(x: unknown): x is PluginDefinition {
|
|
83
|
+
if (!x || typeof x !== "object") return false;
|
|
84
|
+
const d = x as Record<string, unknown>;
|
|
85
|
+
if (typeof d["name"] !== "string") return false;
|
|
86
|
+
const toolsOk =
|
|
87
|
+
d["tools"] === undefined ||
|
|
88
|
+
(Array.isArray(d["tools"]) &&
|
|
89
|
+
d["tools"].every(
|
|
90
|
+
(t) => t && typeof (t as PluginTool).name === "string" && typeof (t as PluginTool).handler === "function" && typeof (t as PluginTool).schema === "object",
|
|
91
|
+
));
|
|
92
|
+
const actionsOk =
|
|
93
|
+
d["actions"] === undefined ||
|
|
94
|
+
(Array.isArray(d["actions"]) && d["actions"].every((a) => a && typeof (a as PluginAction).name === "string" && typeof (a as PluginAction).handler === "function"));
|
|
95
|
+
return toolsOk && actionsOk;
|
|
96
|
+
}
|