@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,85 @@
|
|
|
1
|
+
import type { RegistryNode, RegistrySnapshot } from "../control-plane/registry.js";
|
|
2
|
+
import type { ModelTier } from "../schemas/common.js";
|
|
3
|
+
import { AgentRuntime, type AgentRuntimeDeps } from "./agent.js";
|
|
4
|
+
import type { AuditSink } from "../trust/audit.js";
|
|
5
|
+
|
|
6
|
+
export interface LifecycleDeps extends AgentRuntimeDeps {
|
|
7
|
+
audit: AuditSink;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A node id is a manager-altitude node if it has children. */
|
|
11
|
+
function tierForNode(node: RegistryNode): ModelTier {
|
|
12
|
+
return node.childIds.length > 0 ? "opus" : "sonnet";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Stable fingerprint of the parts of a node that affect runtime behavior. */
|
|
16
|
+
function fingerprint(node: RegistryNode): string {
|
|
17
|
+
return JSON.stringify({ spec: node.spec, tools: node.tools });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Owns the live set of agent runtimes and reconciles it against registry
|
|
22
|
+
* snapshots. Reconciliation is a diff:
|
|
23
|
+
* - new node → spawn a runtime
|
|
24
|
+
* - removed node → drain it, then drop it (in-flight work finishes)
|
|
25
|
+
* - changed node config → swap config in place (no disruption to a running task)
|
|
26
|
+
*
|
|
27
|
+
* Editing a folder thus flows: watcher compiles a snapshot → lifecycle diffs →
|
|
28
|
+
* agents are spawned/drained/updated deliberately. Nothing mutates a running
|
|
29
|
+
* agent directly.
|
|
30
|
+
*/
|
|
31
|
+
export class Lifecycle {
|
|
32
|
+
private readonly agents = new Map<string, AgentRuntime>();
|
|
33
|
+
private readonly fingerprints = new Map<string, string>();
|
|
34
|
+
private appliedVersion = -1;
|
|
35
|
+
|
|
36
|
+
constructor(private readonly deps: LifecycleDeps) {}
|
|
37
|
+
|
|
38
|
+
get(nodeId: string): AgentRuntime | undefined {
|
|
39
|
+
return this.agents.get(nodeId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
all(): AgentRuntime[] {
|
|
43
|
+
return [...this.agents.values()];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
version(): number {
|
|
47
|
+
return this.appliedVersion;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async applySnapshot(snapshot: RegistrySnapshot): Promise<void> {
|
|
51
|
+
const next = new Set(snapshot.nodes.keys());
|
|
52
|
+
|
|
53
|
+
// Removed nodes: drain and drop.
|
|
54
|
+
for (const id of [...this.agents.keys()]) {
|
|
55
|
+
if (!next.has(id)) {
|
|
56
|
+
const agent = this.agents.get(id)!;
|
|
57
|
+
agent.drain();
|
|
58
|
+
this.agents.delete(id);
|
|
59
|
+
this.fingerprints.delete(id);
|
|
60
|
+
await this.deps.audit.append("agent.drained", { nodeId: id, data: {} });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Added or changed nodes.
|
|
65
|
+
for (const node of snapshot.nodes.values()) {
|
|
66
|
+
const existing = this.agents.get(node.id);
|
|
67
|
+
const fp = fingerprint(node);
|
|
68
|
+
if (!existing) {
|
|
69
|
+
const agent = new AgentRuntime(node, this.deps, tierForNode(node));
|
|
70
|
+
this.agents.set(node.id, agent);
|
|
71
|
+
this.fingerprints.set(node.id, fp);
|
|
72
|
+
await this.deps.audit.append("agent.spawned", {
|
|
73
|
+
nodeId: node.id,
|
|
74
|
+
data: { name: node.spec.name, role: node.spec.role },
|
|
75
|
+
});
|
|
76
|
+
} else if (this.fingerprints.get(node.id) !== fp) {
|
|
77
|
+
existing.updateNode(node);
|
|
78
|
+
this.fingerprints.set(node.id, fp);
|
|
79
|
+
await this.deps.audit.append("agent.updated", { nodeId: node.id, data: {} });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.appliedVersion = snapshot.version;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Office actions are consequential business actions (send email, send a
|
|
6
|
+
* proposal, deliver to a client). They are defined once here and used by BOTH:
|
|
7
|
+
* - the in-process MCP tool ([officeTools.ts]) — so the model can *request* them
|
|
8
|
+
* (which routes through the approval gate), and
|
|
9
|
+
* - the async ActionExecutor — which *performs* the approved action.
|
|
10
|
+
*
|
|
11
|
+
* The effect is deliberately simple (append a durable record to the run's
|
|
12
|
+
* `shared/_office_log.md`); wiring a real provider (SMTP, CRM, delivery portal)
|
|
13
|
+
* means replacing the body of `runOfficeAction` while the approval semantics
|
|
14
|
+
* stay identical.
|
|
15
|
+
*/
|
|
16
|
+
export const OFFICE_ACTIONS = {
|
|
17
|
+
send_email: { verb: "email", description: "Send an outbound email. Consequential and externally visible." },
|
|
18
|
+
send_proposal: { verb: "proposal", description: "Send a proposal/quote to a prospect. Consequential and externally visible." },
|
|
19
|
+
deliver_to_client: { verb: "delivery", description: "Deliver final files to a client. Irreversible client-facing action." },
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
export type OfficeActionName = keyof typeof OFFICE_ACTIONS;
|
|
23
|
+
export const OFFICE_TOOL_NAMES = Object.keys(OFFICE_ACTIONS) as OfficeActionName[];
|
|
24
|
+
|
|
25
|
+
export function isOfficeAction(name: string): name is OfficeActionName {
|
|
26
|
+
return name in OFFICE_ACTIONS;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface OfficeActionContext {
|
|
30
|
+
/** Workspace root; the action writes under `cwd/shared`. */
|
|
31
|
+
cwd: string;
|
|
32
|
+
nodeId?: string;
|
|
33
|
+
runId?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface OfficeActionResult {
|
|
37
|
+
ok: boolean;
|
|
38
|
+
result?: string;
|
|
39
|
+
error?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Perform an office action deterministically (no model involved). */
|
|
43
|
+
export async function runOfficeAction(
|
|
44
|
+
name: string,
|
|
45
|
+
input: unknown,
|
|
46
|
+
ctx: OfficeActionContext,
|
|
47
|
+
): Promise<OfficeActionResult> {
|
|
48
|
+
if (!isOfficeAction(name)) return { ok: false, error: `unknown office action "${name}"` };
|
|
49
|
+
const def = OFFICE_ACTIONS[name];
|
|
50
|
+
try {
|
|
51
|
+
const dir = path.join(ctx.cwd, "shared");
|
|
52
|
+
await fs.mkdir(dir, { recursive: true });
|
|
53
|
+
await fs.appendFile(path.join(dir, "_office_log.md"), `- ${def.verb}: ${JSON.stringify(input)}\n`, "utf8");
|
|
54
|
+
return { ok: true, result: `${def.verb} recorded in shared/_office_log.md` };
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
|
|
3
|
+
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
4
|
+
import { OFFICE_ACTIONS, OFFICE_TOOL_NAMES, isOfficeAction, runOfficeAction } from "./officeActions.js";
|
|
5
|
+
|
|
6
|
+
export { OFFICE_TOOL_NAMES } from "./officeActions.js";
|
|
7
|
+
|
|
8
|
+
/** Zod input shapes for the MCP tool definitions (executor doesn't need these). */
|
|
9
|
+
const SHAPES: Record<string, z.ZodRawShape> = {
|
|
10
|
+
send_email: { to: z.string().describe("recipient"), subject: z.string().optional(), body: z.string().optional() },
|
|
11
|
+
send_proposal: { to: z.string().describe("recipient"), summary: z.string().optional() },
|
|
12
|
+
deliver_to_client: {
|
|
13
|
+
to: z.string().optional(),
|
|
14
|
+
files: z.string().optional().describe("what is being delivered"),
|
|
15
|
+
summary: z.string().optional(),
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build an in-process MCP server exposing only the office tools the agent was
|
|
21
|
+
* granted. The tool handlers delegate to the shared `runOfficeAction`, so the
|
|
22
|
+
* synchronous MCP path and the async executor path do exactly the same thing.
|
|
23
|
+
* Returns null if no office tool was granted (no MCP overhead added).
|
|
24
|
+
*
|
|
25
|
+
* Note: under the platform's default *deferred* approval mode these tools are
|
|
26
|
+
* gated to "deny" and never actually execute here — they exist so the model can
|
|
27
|
+
* *request* the action, which the broker records as a proposal. The real effect
|
|
28
|
+
* happens later via the executor on human approval.
|
|
29
|
+
*/
|
|
30
|
+
export function buildOfficeServer(grantedNames: string[], cwd: string): McpServerConfig | null {
|
|
31
|
+
const granted = grantedNames.filter(isOfficeAction);
|
|
32
|
+
if (granted.length === 0) return null;
|
|
33
|
+
|
|
34
|
+
const tools = granted.map((name) =>
|
|
35
|
+
tool(name, OFFICE_ACTIONS[name].description, SHAPES[name]!, async (args: Record<string, unknown>) => {
|
|
36
|
+
const r = await runOfficeAction(name, args, { cwd });
|
|
37
|
+
return { content: [{ type: "text" as const, text: r.ok ? (r.result ?? "OK") : `error: ${r.error}` }] };
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return createSdkMcpServer({ name: "office", version: "1.0.0", tools });
|
|
42
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import type { Options, McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
3
|
+
import type { AgentEngine, EngineRequest, EngineResult, EngineToolUse } from "./engine.js";
|
|
4
|
+
import { emptyUsage, type Usage } from "../domain/types.js";
|
|
5
|
+
import { usageFor } from "../domain/pricing.js";
|
|
6
|
+
import type { McpServerSpec, ToolsConfig } from "../schemas/tools.js";
|
|
7
|
+
import { MODEL_IDS } from "../schemas/common.js";
|
|
8
|
+
import { buildOfficeServer } from "./officeTools.js";
|
|
9
|
+
import { buildGenericMemoryServer } from "../memory/genericTools.js";
|
|
10
|
+
import { buildPluginServer } from "../plugins/server.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Assemble the MCP servers for a call: declared servers + in-process servers for
|
|
14
|
+
* the granted tools — `office` actions, generic `mem` primitives (text/json/queue),
|
|
15
|
+
* and any team-provided `plugin` tools. Domain connectors (X/PDL, watchlists, etc.)
|
|
16
|
+
* live in team plugins, not the runtime. The plugin/mem servers are built only when a
|
|
17
|
+
* `toolContext` is present (FakeEngine omits it). Exported so the wiring can be
|
|
18
|
+
* asserted without an API key.
|
|
19
|
+
*/
|
|
20
|
+
export function assembleMcpServers(req: EngineRequest): Record<string, McpServerConfig> {
|
|
21
|
+
// Per-node credentials (the agent's `.env` chain); resolved for declared MCP servers.
|
|
22
|
+
const nodeEnv = req.nodeEnv ?? {};
|
|
23
|
+
|
|
24
|
+
const servers = toSdkMcpServers(req.tools, nodeEnv);
|
|
25
|
+
const grantedNames = req.tools.tools.filter((t) => t.policy !== "deny").map((t) => t.name);
|
|
26
|
+
|
|
27
|
+
const office = buildOfficeServer(grantedNames, req.cwd);
|
|
28
|
+
if (office) servers["office"] = office;
|
|
29
|
+
|
|
30
|
+
if (req.toolContext) {
|
|
31
|
+
// Generic, domain-agnostic memory primitives (text / json / queue).
|
|
32
|
+
const mem = buildGenericMemoryServer(grantedNames, {
|
|
33
|
+
nodeId: req.toolContext.nodeId,
|
|
34
|
+
managerNodeId: req.toolContext.managerNodeId,
|
|
35
|
+
memory: req.toolContext.memory,
|
|
36
|
+
});
|
|
37
|
+
if (mem) servers["mem"] = mem;
|
|
38
|
+
// Team-provided plugin tools (scoped to this node's team).
|
|
39
|
+
if (req.toolContext.pluginTools) {
|
|
40
|
+
const plugin = buildPluginServer(grantedNames, req.toolContext.pluginTools, {
|
|
41
|
+
nodeId: req.toolContext.nodeId,
|
|
42
|
+
managerNodeId: req.toolContext.managerNodeId,
|
|
43
|
+
memory: req.toolContext.memory,
|
|
44
|
+
cwd: req.cwd,
|
|
45
|
+
env: req.toolContext.pluginEnv ?? {},
|
|
46
|
+
});
|
|
47
|
+
if (plugin) servers["plugin"] = plugin;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return servers;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Translate our MCP specs into the SDK's McpServerConfig, resolving credentials
|
|
55
|
+
* from the node's scoped env first, then the host `process.env`. So a per-agent
|
|
56
|
+
* `.env` key reaches an external MCP's stdio env or `${VAR}` http headers.
|
|
57
|
+
*/
|
|
58
|
+
function toSdkMcpServers(tools: ToolsConfig, nodeEnv: Record<string, string>): Record<string, McpServerConfig> {
|
|
59
|
+
const out: Record<string, McpServerConfig> = {};
|
|
60
|
+
for (const [name, spec] of Object.entries(tools.mcpServers)) {
|
|
61
|
+
out[name] = toSdkMcpServer(spec, nodeEnv);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Substitute `${VAR}` placeholders from the node's env, then process.env. */
|
|
67
|
+
function subst(value: string, nodeEnv: Record<string, string>): string {
|
|
68
|
+
return value.replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (_m, k: string) => nodeEnv[k] ?? process.env[k] ?? "");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function toSdkMcpServer(spec: McpServerSpec, nodeEnv: Record<string, string>): McpServerConfig {
|
|
72
|
+
if (spec.type === "stdio") {
|
|
73
|
+
const env: Record<string, string> = {};
|
|
74
|
+
for (const key of spec.env ?? []) {
|
|
75
|
+
const val = nodeEnv[key] ?? process.env[key];
|
|
76
|
+
if (val !== undefined) env[key] = val;
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
type: "stdio",
|
|
80
|
+
command: spec.command,
|
|
81
|
+
...(spec.args ? { args: spec.args } : {}),
|
|
82
|
+
...(Object.keys(env).length ? { env } : {}),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// http | sse — substitute ${VAR} placeholders in header values from node env.
|
|
86
|
+
const headers = spec.headers
|
|
87
|
+
? Object.fromEntries(Object.entries(spec.headers).map(([k, v]) => [k, subst(v, nodeEnv)]))
|
|
88
|
+
: undefined;
|
|
89
|
+
return {
|
|
90
|
+
type: spec.type,
|
|
91
|
+
url: spec.url,
|
|
92
|
+
...(headers ? { headers } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The production AgentEngine: drives the Claude Agent SDK's agent loop.
|
|
98
|
+
*
|
|
99
|
+
* - `canUseTool` is wired to the runtime's approval gate, so every tool call is
|
|
100
|
+
* gated by our permission policy / HITL queue — not the SDK's own prompts.
|
|
101
|
+
* - The kill-switch/budget abort signal is passed straight through.
|
|
102
|
+
* - Usage is reported per assistant turn so the runtime can enforce token/cost
|
|
103
|
+
* budgets between turns.
|
|
104
|
+
*
|
|
105
|
+
* Requires ANTHROPIC_API_KEY (or an `ant auth login` profile) in the environment.
|
|
106
|
+
*/
|
|
107
|
+
export class SdkEngine implements AgentEngine {
|
|
108
|
+
constructor() {
|
|
109
|
+
// Claude Code (the SDK) uses a family of models, not just the one we pass
|
|
110
|
+
// per agent: a background "haiku" for cheap auxiliary work (summarization,
|
|
111
|
+
// topic/title detection) and a default "sonnet"/"opus" for others. Left
|
|
112
|
+
// alone, this build's default sonnet is the older 4-5. Pin all three to
|
|
113
|
+
// current-gen so background calls stay on models we actually chose. Any
|
|
114
|
+
// value the operator already exported is respected.
|
|
115
|
+
process.env["ANTHROPIC_DEFAULT_OPUS_MODEL"] ??= MODEL_IDS.opus;
|
|
116
|
+
process.env["ANTHROPIC_DEFAULT_SONNET_MODEL"] ??= MODEL_IDS.sonnet;
|
|
117
|
+
process.env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] ??= MODEL_IDS.haiku;
|
|
118
|
+
// Suppress Claude Code's non-essential network traffic (telemetry, auto-update,
|
|
119
|
+
// error reporting). Note: this does NOT stop its baked-in background model
|
|
120
|
+
// calls (title/topic generation, etc.) — only the ApiEngine avoids those.
|
|
121
|
+
process.env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] ??= "1";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async run(req: EngineRequest): Promise<EngineResult> {
|
|
125
|
+
const abortController = new AbortController();
|
|
126
|
+
// Bridge the runtime's signal into the SDK's own AbortController.
|
|
127
|
+
if (req.signal.aborted) abortController.abort();
|
|
128
|
+
else req.signal.addEventListener("abort", () => abortController.abort(), { once: true });
|
|
129
|
+
|
|
130
|
+
const toolUses: EngineToolUse[] = [];
|
|
131
|
+
|
|
132
|
+
const options: Options = {
|
|
133
|
+
model: req.model,
|
|
134
|
+
systemPrompt: req.systemPrompt,
|
|
135
|
+
cwd: req.cwd,
|
|
136
|
+
abortController,
|
|
137
|
+
// Scope the built-in toolset to only what this agent needs. `[]` disables
|
|
138
|
+
// all built-in tools, dropping thousands of tokens of schemas per call.
|
|
139
|
+
tools: req.builtinTools,
|
|
140
|
+
...(req.maxTurns !== undefined ? { maxTurns: req.maxTurns } : {}),
|
|
141
|
+
mcpServers: assembleMcpServers(req),
|
|
142
|
+
permissionMode: "default",
|
|
143
|
+
canUseTool: async (toolName, input) => {
|
|
144
|
+
toolUses.push({ name: toolName, input });
|
|
145
|
+
const decision = await req.decide({ name: toolName, input });
|
|
146
|
+
if (decision === "allow") {
|
|
147
|
+
return { behavior: "allow", updatedInput: input };
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
behavior: "deny",
|
|
151
|
+
message:
|
|
152
|
+
"Queued for human approval — do not retry; continue without it and note it as pending in your summary.",
|
|
153
|
+
interrupt: false,
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
let finalText = "";
|
|
159
|
+
let usage: Usage = emptyUsage();
|
|
160
|
+
let stopReason: EngineResult["stopReason"] = "done";
|
|
161
|
+
let error: string | undefined;
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
for await (const message of query({ prompt: req.prompt, options })) {
|
|
165
|
+
if (message.type === "assistant") {
|
|
166
|
+
// Report this turn's usage (cache-aware) so the runtime can enforce
|
|
167
|
+
// budgets mid-run and surface cache reads/writes.
|
|
168
|
+
const u = message.message.usage;
|
|
169
|
+
if (u) {
|
|
170
|
+
req.onUsage?.(
|
|
171
|
+
usageFor(
|
|
172
|
+
req.model,
|
|
173
|
+
u.input_tokens ?? 0,
|
|
174
|
+
u.output_tokens ?? 0,
|
|
175
|
+
u.cache_read_input_tokens ?? 0,
|
|
176
|
+
u.cache_creation_input_tokens ?? 0,
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
} else if (message.type === "result") {
|
|
181
|
+
finalText = message.subtype === "success" ? message.result : "";
|
|
182
|
+
// The SDK's total_cost_usd is authoritative (already cache-aware); keep
|
|
183
|
+
// the token breakdown for visibility.
|
|
184
|
+
usage = {
|
|
185
|
+
inputTokens: message.usage.input_tokens ?? 0,
|
|
186
|
+
outputTokens: message.usage.output_tokens ?? 0,
|
|
187
|
+
cacheReadTokens: message.usage.cache_read_input_tokens ?? 0,
|
|
188
|
+
cacheCreationTokens: message.usage.cache_creation_input_tokens ?? 0,
|
|
189
|
+
usd: message.total_cost_usd,
|
|
190
|
+
};
|
|
191
|
+
if (message.subtype !== "success") {
|
|
192
|
+
stopReason = "error";
|
|
193
|
+
error = message.errors.join("; ") || message.subtype;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} catch (err) {
|
|
198
|
+
if (abortController.signal.aborted) {
|
|
199
|
+
return { text: finalText, usage, stopReason: "aborted", toolUses };
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
text: "",
|
|
203
|
+
usage,
|
|
204
|
+
stopReason: "error",
|
|
205
|
+
toolUses,
|
|
206
|
+
error: err instanceof Error ? err.message : String(err),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (abortController.signal.aborted) stopReason = "aborted";
|
|
211
|
+
return { text: finalText, usage, stopReason, toolUses, ...(error ? { error } : {}) };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { Budget, ModelRef } from "./common.js";
|
|
3
|
+
import { parseFrontmatter } from "./frontmatter.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The authored shape of an `agent.md` file. Frontmatter carries machine fields;
|
|
7
|
+
* the markdown body becomes the agent's system prompt.
|
|
8
|
+
*
|
|
9
|
+
* Hierarchy note: an agent's *position* in the folder tree (its parent and
|
|
10
|
+
* children) defines escalation and permission scope, NOT execution flow. Who
|
|
11
|
+
* does what work is decided by processes (see ProcessSpec) and by the
|
|
12
|
+
* orchestrator dispatching task contracts.
|
|
13
|
+
*/
|
|
14
|
+
export const AgentSpec = z
|
|
15
|
+
.object({
|
|
16
|
+
/** Human-readable name, e.g. "Sales Manager". */
|
|
17
|
+
name: z.string().min(1),
|
|
18
|
+
/** Role title used in prompts and routing, e.g. "manager", "researcher". */
|
|
19
|
+
role: z.string().min(1).optional(),
|
|
20
|
+
/** Model tier or explicit id. Defaults are applied by the runtime per altitude. */
|
|
21
|
+
model: ModelRef.optional(),
|
|
22
|
+
/**
|
|
23
|
+
* How much latitude this agent has when acting.
|
|
24
|
+
* - `orchestrated`: only acts on dispatched task contracts; no self-directed loops.
|
|
25
|
+
* - `bounded`: may act autonomously within its budget and approval policy.
|
|
26
|
+
*/
|
|
27
|
+
autonomy: z.enum(["orchestrated", "bounded"]).default("orchestrated"),
|
|
28
|
+
/** Default budget ceiling applied to this agent's tasks unless overridden. */
|
|
29
|
+
budget: Budget.optional(),
|
|
30
|
+
/** Free-text escalation guidance surfaced to the agent. */
|
|
31
|
+
escalation: z.string().optional(),
|
|
32
|
+
/** The system prompt. Sourced from the markdown body, never frontmatter. */
|
|
33
|
+
systemPrompt: z.string().min(1, "agent.md must have a non-empty body (the system prompt)"),
|
|
34
|
+
})
|
|
35
|
+
.strict();
|
|
36
|
+
|
|
37
|
+
export type AgentSpec = z.infer<typeof AgentSpec>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse and validate raw `agent.md` content into an AgentSpec.
|
|
41
|
+
* Throws a ZodError on invalid input — the caller (registry compiler) turns
|
|
42
|
+
* that into a diagnostic and keeps the last-good snapshot.
|
|
43
|
+
*/
|
|
44
|
+
export function parseAgentSpec(source: string): AgentSpec {
|
|
45
|
+
const { frontmatter, body } = parseFrontmatter(source);
|
|
46
|
+
return AgentSpec.parse({ ...frontmatter, systemPrompt: body });
|
|
47
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Logical model tiers. Authors pick a tier; the runtime resolves it to a
|
|
5
|
+
* concrete model id (see `resolveModel`). Orchestrator/manager agents default
|
|
6
|
+
* to opus for planning; workers default to sonnet. An author may also pass a
|
|
7
|
+
* raw model id string to pin an exact model.
|
|
8
|
+
*/
|
|
9
|
+
export const ModelTier = z.enum(["opus", "sonnet", "haiku"]);
|
|
10
|
+
export type ModelTier = z.infer<typeof ModelTier>;
|
|
11
|
+
|
|
12
|
+
/** Concrete model ids the platform knows about (latest as of build). */
|
|
13
|
+
export const MODEL_IDS: Record<ModelTier, string> = {
|
|
14
|
+
opus: "claude-opus-4-8",
|
|
15
|
+
sonnet: "claude-sonnet-4-6",
|
|
16
|
+
haiku: "claude-haiku-4-5",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** A model field accepts a known tier or an explicit model-id string. */
|
|
20
|
+
export const ModelRef = z.union([ModelTier, z.string().min(1)]);
|
|
21
|
+
export type ModelRef = z.infer<typeof ModelRef>;
|
|
22
|
+
|
|
23
|
+
export function resolveModel(ref: ModelRef | undefined, fallback: ModelTier): string {
|
|
24
|
+
const value = ref ?? fallback;
|
|
25
|
+
if (value in MODEL_IDS) return MODEL_IDS[value as ModelTier];
|
|
26
|
+
return value; // explicit model id passed through verbatim
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A budget bounds the work an agent or task may do before it must terminate or
|
|
31
|
+
* escalate. All fields are optional ceilings; the runtime enforces whichever
|
|
32
|
+
* are present. This is the core guard against runaway autonomous loops.
|
|
33
|
+
*/
|
|
34
|
+
export const Budget = z
|
|
35
|
+
.object({
|
|
36
|
+
tokens: z.number().int().positive().optional(),
|
|
37
|
+
usd: z.number().positive().optional(),
|
|
38
|
+
seconds: z.number().int().positive().optional(),
|
|
39
|
+
/** Max orchestration turns / dispatch cycles before forced termination. */
|
|
40
|
+
turns: z.number().int().positive().optional(),
|
|
41
|
+
})
|
|
42
|
+
.strict();
|
|
43
|
+
export type Budget = z.infer<typeof Budget>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Per-tool permission policy. `auto` runs without asking, `ask` routes through
|
|
47
|
+
* the human approval queue (HITL), `deny` blocks the tool entirely. The default
|
|
48
|
+
* is intentionally `ask` — consequential actions should pause for a human until
|
|
49
|
+
* an owner explicitly trusts them.
|
|
50
|
+
*/
|
|
51
|
+
export const PermissionPolicy = z.enum(["auto", "ask", "deny"]);
|
|
52
|
+
export type PermissionPolicy = z.infer<typeof PermissionPolicy>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { parse as parseYaml } from "yaml";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Splits a markdown document into its YAML frontmatter and body.
|
|
5
|
+
*
|
|
6
|
+
* Supports the conventional `---\n<yaml>\n---\n<body>` form. If no frontmatter
|
|
7
|
+
* fence is present, the whole document is treated as the body with empty
|
|
8
|
+
* frontmatter — this keeps authoring forgiving (an `agent.md` that is pure prose
|
|
9
|
+
* is still valid; only the schema decides what is required).
|
|
10
|
+
*/
|
|
11
|
+
export interface ParsedDocument {
|
|
12
|
+
frontmatter: Record<string, unknown>;
|
|
13
|
+
body: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
17
|
+
|
|
18
|
+
export function parseFrontmatter(source: string): ParsedDocument {
|
|
19
|
+
const match = source.match(FRONTMATTER_RE);
|
|
20
|
+
if (!match) {
|
|
21
|
+
return { frontmatter: {}, body: source.trim() };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const [, rawYaml = "", body = ""] = match;
|
|
25
|
+
let frontmatter: Record<string, unknown> = {};
|
|
26
|
+
if (rawYaml.trim().length > 0) {
|
|
27
|
+
const parsed: unknown = parseYaml(rawYaml);
|
|
28
|
+
if (parsed !== null && typeof parsed === "object") {
|
|
29
|
+
frontmatter = parsed as Record<string, unknown>;
|
|
30
|
+
} else if (parsed !== null && parsed !== undefined) {
|
|
31
|
+
throw new Error("Frontmatter must be a YAML mapping (key: value pairs)");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { frontmatter, body: body.trim() };
|
|
36
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { Budget } from "./common.js";
|
|
3
|
+
import { parseFrontmatter } from "./frontmatter.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What kicks off a process. Agents are event-driven and otherwise idle — a
|
|
7
|
+
* process does no work until its trigger fires.
|
|
8
|
+
* - `manual`: started by the owner (chat / button / API).
|
|
9
|
+
* - `schedule`: started on a cron expression.
|
|
10
|
+
* - `event`: started by an external event (webhook / MCP) keyed by `event`.
|
|
11
|
+
*/
|
|
12
|
+
export const ProcessTrigger = z.discriminatedUnion("type", [
|
|
13
|
+
z.object({ type: z.literal("manual") }).strict(),
|
|
14
|
+
z.object({ type: z.literal("schedule"), cron: z.string().min(1) }).strict(),
|
|
15
|
+
z.object({ type: z.literal("event"), event: z.string().min(1) }).strict(),
|
|
16
|
+
]);
|
|
17
|
+
export type ProcessTrigger = z.infer<typeof ProcessTrigger>;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A first-class playbook. Processes are how work actually flows — they cut
|
|
21
|
+
* across the org hierarchy. The `owner` role decomposes the playbook into task
|
|
22
|
+
* contracts and dispatches them; `participants` are roles the process may
|
|
23
|
+
* involve.
|
|
24
|
+
*
|
|
25
|
+
* The markdown body is the playbook itself (the steps, in prose), handed to the
|
|
26
|
+
* owning agent as the goal to decompose.
|
|
27
|
+
*/
|
|
28
|
+
export const ProcessSpec = z
|
|
29
|
+
.object({
|
|
30
|
+
name: z.string().min(1),
|
|
31
|
+
trigger: ProcessTrigger.default({ type: "manual" }),
|
|
32
|
+
/** Role that owns execution (decomposes + dispatches). Must resolve to an agent. */
|
|
33
|
+
owner: z.string().min(1),
|
|
34
|
+
/** Roles this process may involve, for routing/validation. */
|
|
35
|
+
participants: z.array(z.string().min(1)).default([]),
|
|
36
|
+
/** Explicit, checkable completion criteria. Drives termination. */
|
|
37
|
+
definitionOfDone: z.string().min(1),
|
|
38
|
+
/**
|
|
39
|
+
* Step names (or tool names) that always require human approval regardless
|
|
40
|
+
* of per-tool policy — a process-level safety gate.
|
|
41
|
+
*/
|
|
42
|
+
approvals: z.array(z.string().min(1)).default([]),
|
|
43
|
+
/** Budget ceiling for one run of this process. */
|
|
44
|
+
budget: Budget.optional(),
|
|
45
|
+
/** The playbook steps, sourced from the markdown body. */
|
|
46
|
+
playbook: z.string().min(1, "process.md must have a non-empty body (the playbook)"),
|
|
47
|
+
})
|
|
48
|
+
.strict();
|
|
49
|
+
export type ProcessSpec = z.infer<typeof ProcessSpec>;
|
|
50
|
+
|
|
51
|
+
/** Parse and validate raw `*.process.md` content. Throws ZodError on invalid input. */
|
|
52
|
+
export function parseProcessSpec(source: string): ProcessSpec {
|
|
53
|
+
const { frontmatter, body } = parseFrontmatter(source);
|
|
54
|
+
return ProcessSpec.parse({ ...frontmatter, playbook: body });
|
|
55
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { PermissionPolicy } from "./common.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One MCP server the agent may connect to. Mirrors the Claude Agent SDK's
|
|
6
|
+
* McpServerConfig shape so entries can be passed through to the runtime with
|
|
7
|
+
* minimal translation. Credentials in `env` are resolved from the host
|
|
8
|
+
* environment at spawn time — never stored in the registry snapshot verbatim
|
|
9
|
+
* where they could leak into logs.
|
|
10
|
+
*/
|
|
11
|
+
export const McpServerSpec = z.discriminatedUnion("type", [
|
|
12
|
+
z
|
|
13
|
+
.object({
|
|
14
|
+
type: z.literal("stdio"),
|
|
15
|
+
command: z.string().min(1),
|
|
16
|
+
args: z.array(z.string()).optional(),
|
|
17
|
+
/** Env var names to forward from the host (values resolved at spawn). */
|
|
18
|
+
env: z.array(z.string()).optional(),
|
|
19
|
+
})
|
|
20
|
+
.strict(),
|
|
21
|
+
z
|
|
22
|
+
.object({
|
|
23
|
+
type: z.literal("http"),
|
|
24
|
+
url: z.string().url(),
|
|
25
|
+
headers: z.record(z.string()).optional(),
|
|
26
|
+
})
|
|
27
|
+
.strict(),
|
|
28
|
+
z
|
|
29
|
+
.object({
|
|
30
|
+
type: z.literal("sse"),
|
|
31
|
+
url: z.string().url(),
|
|
32
|
+
headers: z.record(z.string()).optional(),
|
|
33
|
+
})
|
|
34
|
+
.strict(),
|
|
35
|
+
]);
|
|
36
|
+
export type McpServerSpec = z.infer<typeof McpServerSpec>;
|
|
37
|
+
|
|
38
|
+
/** A tool the agent may use, with its human-in-the-loop permission policy. */
|
|
39
|
+
export const ToolGrant = z
|
|
40
|
+
.object({
|
|
41
|
+
name: z.string().min(1),
|
|
42
|
+
/** Default `ask` — consequential tools pause for human approval. */
|
|
43
|
+
policy: PermissionPolicy.default("ask"),
|
|
44
|
+
/** Optional note shown to the approver explaining what the tool does. */
|
|
45
|
+
description: z.string().optional(),
|
|
46
|
+
})
|
|
47
|
+
.strict();
|
|
48
|
+
export type ToolGrant = z.infer<typeof ToolGrant>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The authored shape of a `tools.json` file: which tools and MCP servers an
|
|
52
|
+
* agent may use, and the permission policy for each tool.
|
|
53
|
+
*/
|
|
54
|
+
export const ToolsConfig = z
|
|
55
|
+
.object({
|
|
56
|
+
tools: z.array(ToolGrant).default([]),
|
|
57
|
+
mcpServers: z.record(McpServerSpec).default({}),
|
|
58
|
+
/** Default policy for any tool not explicitly listed in `tools`. */
|
|
59
|
+
defaultPolicy: PermissionPolicy.default("ask"),
|
|
60
|
+
/**
|
|
61
|
+
* Which built-in SDK file tools to auto-expose. `readonly` (default) gives
|
|
62
|
+
* the agent Read/Glob/Grep so it can read its staged files. `none` withholds
|
|
63
|
+
* them — for pure-judgment / memory-only agents that have no files to read
|
|
64
|
+
* and would otherwise waste turns wandering the filesystem. Explicitly
|
|
65
|
+
* granted built-ins (e.g. WebSearch) are unaffected either way.
|
|
66
|
+
*/
|
|
67
|
+
builtins: z.enum(["readonly", "none"]).optional(),
|
|
68
|
+
})
|
|
69
|
+
.strict();
|
|
70
|
+
export type ToolsConfig = z.infer<typeof ToolsConfig>;
|
|
71
|
+
|
|
72
|
+
/** Parse and validate raw `tools.json` content. Throws ZodError on invalid input. */
|
|
73
|
+
export function parseToolsConfig(source: string): ToolsConfig {
|
|
74
|
+
const data: unknown = JSON.parse(source);
|
|
75
|
+
return ToolsConfig.parse(data);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The config an agent gets when it has no `tools.json` — no tools, all `ask`. */
|
|
79
|
+
export const EMPTY_TOOLS_CONFIG: ToolsConfig = {
|
|
80
|
+
tools: [],
|
|
81
|
+
mcpServers: {},
|
|
82
|
+
defaultPolicy: "ask",
|
|
83
|
+
};
|