@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,180 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { newId, systemClock, type Clock } from "../domain/ids.js";
|
|
3
|
+
import type { ApprovalRequest, PermissionDecision } from "../domain/types.js";
|
|
4
|
+
import type { PermissionPolicy } from "../schemas/common.js";
|
|
5
|
+
import type { ToolsConfig } from "../schemas/tools.js";
|
|
6
|
+
import type { AuditSink } from "./audit.js";
|
|
7
|
+
import type { ProposalStore } from "./proposals.js";
|
|
8
|
+
|
|
9
|
+
/** Strip an MCP namespace (`mcp__server__tool`) to the bare tool name. */
|
|
10
|
+
function bareName(toolName: string): string {
|
|
11
|
+
return toolName.includes("__") ? toolName.split("__").pop()! : toolName;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read-only built-in tools the runtime auto-exposes so agents can read their
|
|
16
|
+
* staged files. They are safe and their results are needed inline, so they
|
|
17
|
+
* default to `auto` — gating a read as a proposal both breaks the agent (it
|
|
18
|
+
* never gets the content) and has no executor to perform it later. An author
|
|
19
|
+
* can still override by listing one explicitly in tools.json.
|
|
20
|
+
*/
|
|
21
|
+
export const SAFE_AUTO_TOOLS = ["Read", "Glob", "Grep"];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the effective permission policy for a tool from an agent's config.
|
|
25
|
+
* Precedence: an explicit grant (author intent) → the safe read-only set
|
|
26
|
+
* (`auto`) → the agent's `defaultPolicy`. MCP tools arrive namespaced
|
|
27
|
+
* (`mcp__<server>__<tool>`); we match the bare name too so a grant for
|
|
28
|
+
* `deliver_to_client` governs `mcp__office__deliver_to_client`.
|
|
29
|
+
*/
|
|
30
|
+
export function policyForTool(tools: ToolsConfig, toolName: string): PermissionPolicy {
|
|
31
|
+
const bare = bareName(toolName);
|
|
32
|
+
const grant = tools.tools.find((t) => t.name === toolName || t.name === bare);
|
|
33
|
+
if (grant) return grant.policy;
|
|
34
|
+
if (SAFE_AUTO_TOOLS.includes(bare)) return "auto";
|
|
35
|
+
return tools.defaultPolicy;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DecideRequest {
|
|
39
|
+
nodeId: string;
|
|
40
|
+
toolName: string;
|
|
41
|
+
input: unknown;
|
|
42
|
+
policy: PermissionPolicy;
|
|
43
|
+
rationale?: string;
|
|
44
|
+
runId?: string;
|
|
45
|
+
/** Workspace root, recorded on a deferred proposal so the executor can act there. */
|
|
46
|
+
cwd?: string;
|
|
47
|
+
/** Team-memory scope, recorded so the executor can perform team-scoped writes. */
|
|
48
|
+
managerNodeId?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Decision plus, in deferred mode, the proposal that was queued for approval. */
|
|
52
|
+
export interface DecideResult {
|
|
53
|
+
decision: PermissionDecision;
|
|
54
|
+
proposalId?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type ApprovalMode = "sync" | "deferred";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The human-in-the-loop gate. Every consequential tool call passes through here.
|
|
61
|
+
*
|
|
62
|
+
* Two modes:
|
|
63
|
+
* - **deferred** (default): `ask` records a Proposal and immediately denies, so
|
|
64
|
+
* the agent never blocks — the run completes and a human approves later, out
|
|
65
|
+
* of band. This is what lets the platform run continuously.
|
|
66
|
+
* - **sync**: `ask` blocks until a human resolves it (interactive CLI / tests).
|
|
67
|
+
*
|
|
68
|
+
* `auto`/`deny`/dry-run behave identically in both modes.
|
|
69
|
+
*/
|
|
70
|
+
export class ApprovalBroker extends EventEmitter {
|
|
71
|
+
private readonly waiting = new Map<string, { req: ApprovalRequest; resolve: (d: PermissionDecision) => void }>();
|
|
72
|
+
private readonly mode: ApprovalMode;
|
|
73
|
+
|
|
74
|
+
constructor(
|
|
75
|
+
private readonly audit: AuditSink,
|
|
76
|
+
private readonly opts: { dryRun?: boolean; clock?: Clock; mode?: ApprovalMode; proposals?: ProposalStore } = {},
|
|
77
|
+
) {
|
|
78
|
+
super();
|
|
79
|
+
this.mode = opts.mode ?? "deferred";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private get clock(): Clock {
|
|
83
|
+
return this.opts.clock ?? systemClock;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Sync-mode: currently-blocking approval requests. (Deferred uses ProposalStore.) */
|
|
87
|
+
pending(): ApprovalRequest[] {
|
|
88
|
+
return [...this.waiting.values()].map((w) => w.req);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async decide(req: DecideRequest): Promise<DecideResult> {
|
|
92
|
+
if (this.opts.dryRun) {
|
|
93
|
+
await this.audit.append("tool.dry_run", {
|
|
94
|
+
nodeId: req.nodeId,
|
|
95
|
+
runId: req.runId,
|
|
96
|
+
data: { tool: req.toolName, input: req.input },
|
|
97
|
+
});
|
|
98
|
+
return { decision: "deny" };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (req.policy === "auto") {
|
|
102
|
+
await this.audit.append("tool.auto_allowed", {
|
|
103
|
+
nodeId: req.nodeId,
|
|
104
|
+
runId: req.runId,
|
|
105
|
+
data: { tool: req.toolName },
|
|
106
|
+
});
|
|
107
|
+
return { decision: "allow" };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (req.policy === "deny") {
|
|
111
|
+
await this.audit.append("tool.denied_by_policy", {
|
|
112
|
+
nodeId: req.nodeId,
|
|
113
|
+
runId: req.runId,
|
|
114
|
+
data: { tool: req.toolName },
|
|
115
|
+
});
|
|
116
|
+
return { decision: "deny" };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// policy === "ask"
|
|
120
|
+
return this.mode === "deferred" ? this.defer(req) : this.block(req);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Deferred: queue a proposal and deny immediately (non-blocking). */
|
|
124
|
+
private async defer(req: DecideRequest): Promise<DecideResult> {
|
|
125
|
+
if (!this.opts.proposals) {
|
|
126
|
+
throw new Error("deferred approval mode requires a ProposalStore");
|
|
127
|
+
}
|
|
128
|
+
const proposal = await this.opts.proposals.create({
|
|
129
|
+
nodeId: req.nodeId,
|
|
130
|
+
action: bareName(req.toolName),
|
|
131
|
+
input: req.input,
|
|
132
|
+
cwd: req.cwd ?? "",
|
|
133
|
+
...(req.managerNodeId !== undefined ? { managerNodeId: req.managerNodeId } : {}),
|
|
134
|
+
...(req.rationale !== undefined ? { rationale: req.rationale } : {}),
|
|
135
|
+
...(req.runId !== undefined ? { runId: req.runId } : {}),
|
|
136
|
+
});
|
|
137
|
+
await this.audit.append("proposal.created", {
|
|
138
|
+
nodeId: req.nodeId,
|
|
139
|
+
runId: req.runId,
|
|
140
|
+
data: { proposalId: proposal.id, action: proposal.action, input: req.input },
|
|
141
|
+
});
|
|
142
|
+
return { decision: "deny", proposalId: proposal.id };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Sync: enqueue and block until a human resolves. */
|
|
146
|
+
private async block(req: DecideRequest): Promise<DecideResult> {
|
|
147
|
+
const request: ApprovalRequest = {
|
|
148
|
+
id: newId("apr"),
|
|
149
|
+
nodeId: req.nodeId,
|
|
150
|
+
toolName: req.toolName,
|
|
151
|
+
input: req.input,
|
|
152
|
+
...(req.rationale !== undefined ? { rationale: req.rationale } : {}),
|
|
153
|
+
requestedAt: this.clock.iso(),
|
|
154
|
+
};
|
|
155
|
+
await this.audit.append("approval.requested", {
|
|
156
|
+
nodeId: req.nodeId,
|
|
157
|
+
runId: req.runId,
|
|
158
|
+
data: { approvalId: request.id, tool: req.toolName, input: req.input },
|
|
159
|
+
});
|
|
160
|
+
const decision = await new Promise<PermissionDecision>((resolve) => {
|
|
161
|
+
this.waiting.set(request.id, { req: request, resolve });
|
|
162
|
+
this.emit("requested", request);
|
|
163
|
+
});
|
|
164
|
+
return { decision };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Resolve a pending *sync-mode* approval. No-op if unknown/already resolved. */
|
|
168
|
+
async resolve(approvalId: string, decision: PermissionDecision): Promise<boolean> {
|
|
169
|
+
const entry = this.waiting.get(approvalId);
|
|
170
|
+
if (!entry) return false;
|
|
171
|
+
this.waiting.delete(approvalId);
|
|
172
|
+
await this.audit.append("approval.resolved", {
|
|
173
|
+
nodeId: entry.req.nodeId,
|
|
174
|
+
data: { approvalId, decision, tool: entry.req.toolName },
|
|
175
|
+
});
|
|
176
|
+
entry.resolve(decision);
|
|
177
|
+
this.emit("resolved", approvalId, decision);
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { systemClock, type Clock } from "../domain/ids.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One entry in the append-only audit trail. Every decision, message, tool call,
|
|
7
|
+
* approval, and lifecycle transition is recorded so a run can be fully
|
|
8
|
+
* reconstructed and reviewed. This is non-negotiable for a system a business
|
|
9
|
+
* owner must trust.
|
|
10
|
+
*/
|
|
11
|
+
export interface AuditEvent {
|
|
12
|
+
/** Monotonic sequence within the sink. */
|
|
13
|
+
seq: number;
|
|
14
|
+
at: string;
|
|
15
|
+
type: string;
|
|
16
|
+
nodeId?: string;
|
|
17
|
+
runId?: string;
|
|
18
|
+
data: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AuditSink {
|
|
22
|
+
append(type: string, fields: Omit<Partial<AuditEvent>, "seq" | "at" | "type">): Promise<AuditEvent>;
|
|
23
|
+
all(): readonly AuditEvent[];
|
|
24
|
+
/** Rehydrate prior events from durable storage (called once at startup). */
|
|
25
|
+
load?(): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** In-memory audit sink — used in tests and as the dashboard's live buffer. */
|
|
29
|
+
export class InMemoryAudit implements AuditSink {
|
|
30
|
+
private readonly events: AuditEvent[] = [];
|
|
31
|
+
private seq = 0;
|
|
32
|
+
constructor(private readonly clock: Clock = systemClock) {}
|
|
33
|
+
|
|
34
|
+
async append(
|
|
35
|
+
type: string,
|
|
36
|
+
fields: Omit<Partial<AuditEvent>, "seq" | "at" | "type"> = {},
|
|
37
|
+
): Promise<AuditEvent> {
|
|
38
|
+
const event: AuditEvent = {
|
|
39
|
+
seq: ++this.seq,
|
|
40
|
+
at: this.clock.iso(),
|
|
41
|
+
type,
|
|
42
|
+
data: {},
|
|
43
|
+
...fields,
|
|
44
|
+
};
|
|
45
|
+
this.events.push(event);
|
|
46
|
+
return event;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Load prior events as-is, preserving their seq and continuing the counter. */
|
|
50
|
+
hydrate(events: AuditEvent[]): void {
|
|
51
|
+
for (const e of events) {
|
|
52
|
+
this.events.push(e);
|
|
53
|
+
if (e.seq > this.seq) this.seq = e.seq;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
all(): readonly AuditEvent[] {
|
|
58
|
+
return this.events;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Durable JSONL audit sink that also keeps an in-memory mirror for querying.
|
|
64
|
+
* Writes are serialized so the file is a faithful, ordered record.
|
|
65
|
+
*/
|
|
66
|
+
/**
|
|
67
|
+
* Wraps any AuditSink and tees each event to a sink function (e.g. stderr) for
|
|
68
|
+
* live verbose logging. Persistence/querying delegate to the wrapped sink, so
|
|
69
|
+
* `-v` adds visibility without changing where the audit trail is stored.
|
|
70
|
+
*/
|
|
71
|
+
export class LoggingAudit implements AuditSink {
|
|
72
|
+
constructor(
|
|
73
|
+
private readonly base: AuditSink,
|
|
74
|
+
private readonly emit: (line: string) => void,
|
|
75
|
+
) {}
|
|
76
|
+
|
|
77
|
+
async append(
|
|
78
|
+
type: string,
|
|
79
|
+
fields: Omit<Partial<AuditEvent>, "seq" | "at" | "type"> = {},
|
|
80
|
+
): Promise<AuditEvent> {
|
|
81
|
+
const event = await this.base.append(type, fields);
|
|
82
|
+
this.emit(formatEvent(event));
|
|
83
|
+
return event;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
load(): Promise<void> {
|
|
87
|
+
return this.base.load?.() ?? Promise.resolve();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
all(): readonly AuditEvent[] {
|
|
91
|
+
return this.base.all();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Compact one-line rendering of an audit event for verbose logs. */
|
|
96
|
+
export function formatEvent(e: AuditEvent): string {
|
|
97
|
+
const where = [e.nodeId !== undefined ? `node=${e.nodeId || "(root)"}` : "", e.runId ? `run=${e.runId}` : ""]
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
.join(" ");
|
|
100
|
+
const d = e.data;
|
|
101
|
+
let detail = "";
|
|
102
|
+
switch (e.type) {
|
|
103
|
+
case "process.started":
|
|
104
|
+
detail = `process="${d["process"]}"`;
|
|
105
|
+
break;
|
|
106
|
+
case "process.turn":
|
|
107
|
+
detail = `turn=${d["turn"]} done=${d["done"]} tasks=${d["taskCount"]}${d["assignees"] ? ` → [${(d["assignees"] as string[]).join(", ")}]` : ""}`;
|
|
108
|
+
break;
|
|
109
|
+
case "process.finished":
|
|
110
|
+
detail = `status=${d["status"]} turns=${d["turns"]}`;
|
|
111
|
+
break;
|
|
112
|
+
case "task.started":
|
|
113
|
+
detail = `goal="${String(d["goal"] ?? "").slice(0, 80)}"`;
|
|
114
|
+
break;
|
|
115
|
+
case "task.finished":
|
|
116
|
+
detail = `status=${d["status"]}${d["summary"] ? ` — ${String(d["summary"]).slice(0, 100).replace(/\s+/g, " ")}` : ""}`;
|
|
117
|
+
break;
|
|
118
|
+
case "task.unrouted":
|
|
119
|
+
detail = `role="${d["role"]}"`;
|
|
120
|
+
break;
|
|
121
|
+
case "approval.requested":
|
|
122
|
+
detail = `tool=${d["tool"]} ⏸ awaiting human`;
|
|
123
|
+
break;
|
|
124
|
+
case "approval.resolved":
|
|
125
|
+
detail = `tool=${d["tool"]} → ${d["decision"]}`;
|
|
126
|
+
break;
|
|
127
|
+
case "tool.started":
|
|
128
|
+
case "tool.auto_allowed":
|
|
129
|
+
case "tool.denied_by_policy":
|
|
130
|
+
case "tool.dry_run":
|
|
131
|
+
detail = `tool=${d["tool"]}`;
|
|
132
|
+
break;
|
|
133
|
+
case "message.deadletter":
|
|
134
|
+
detail = `reason=${d["reason"]}`;
|
|
135
|
+
break;
|
|
136
|
+
case "registry.invalid":
|
|
137
|
+
detail = `${(d["diagnostics"] as string[] | undefined)?.length ?? 0} diagnostic(s)`;
|
|
138
|
+
break;
|
|
139
|
+
default:
|
|
140
|
+
detail = "";
|
|
141
|
+
}
|
|
142
|
+
return `· ${e.type}${where ? ` ${where}` : ""}${detail ? ` ${detail}` : ""}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export class JsonlAudit implements AuditSink {
|
|
146
|
+
private readonly mem: InMemoryAudit;
|
|
147
|
+
private writeChain: Promise<void> = Promise.resolve();
|
|
148
|
+
|
|
149
|
+
constructor(
|
|
150
|
+
private readonly filePath: string,
|
|
151
|
+
private readonly clock: Clock = systemClock,
|
|
152
|
+
) {
|
|
153
|
+
this.mem = new InMemoryAudit(clock);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Rehydrate the in-memory mirror from the JSONL file so history survives restarts. */
|
|
157
|
+
async load(): Promise<void> {
|
|
158
|
+
let raw: string;
|
|
159
|
+
try {
|
|
160
|
+
raw = await fs.readFile(this.filePath, "utf8");
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
165
|
+
const events: AuditEvent[] = [];
|
|
166
|
+
for (const line of raw.split("\n")) {
|
|
167
|
+
if (line.trim()) {
|
|
168
|
+
try {
|
|
169
|
+
events.push(JSON.parse(line) as AuditEvent);
|
|
170
|
+
} catch {
|
|
171
|
+
/* skip a partially-written trailing line */
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
this.mem.hydrate(events);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async append(
|
|
179
|
+
type: string,
|
|
180
|
+
fields: Omit<Partial<AuditEvent>, "seq" | "at" | "type"> = {},
|
|
181
|
+
): Promise<AuditEvent> {
|
|
182
|
+
const event = await this.mem.append(type, fields);
|
|
183
|
+
const line = `${JSON.stringify(event)}\n`;
|
|
184
|
+
this.writeChain = this.writeChain.then(async () => {
|
|
185
|
+
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
|
186
|
+
await fs.appendFile(this.filePath, line, "utf8");
|
|
187
|
+
});
|
|
188
|
+
await this.writeChain;
|
|
189
|
+
return event;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
all(): readonly AuditEvent[] {
|
|
193
|
+
return this.mem.all();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Budget } from "../schemas/common.js";
|
|
2
|
+
import { addUsage, emptyUsage, type Usage } from "../domain/types.js";
|
|
3
|
+
import { systemClock, type Clock } from "../domain/ids.js";
|
|
4
|
+
|
|
5
|
+
export type BudgetLimit = "tokens" | "usd" | "seconds" | "turns";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tracks consumption against a Budget. The orchestrator and agent runtime check
|
|
9
|
+
* `exceeded()` before each step and terminate/escalate when a ceiling is hit —
|
|
10
|
+
* this is the mechanism that makes autonomous work bounded rather than a
|
|
11
|
+
* perpetual loop. An empty budget ({}), means "no ceilings" and never trips.
|
|
12
|
+
*/
|
|
13
|
+
export class BudgetMeter {
|
|
14
|
+
private usage: Usage = emptyUsage();
|
|
15
|
+
private turns = 0;
|
|
16
|
+
private readonly startedAt: number;
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
private readonly budget: Budget,
|
|
20
|
+
private readonly clock: Clock = systemClock,
|
|
21
|
+
) {
|
|
22
|
+
this.startedAt = clock.now();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
recordUsage(u: Usage): void {
|
|
26
|
+
this.usage = addUsage(this.usage, u);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
recordTurn(): void {
|
|
30
|
+
this.turns += 1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
elapsedSeconds(): number {
|
|
34
|
+
return (this.clock.now() - this.startedAt) / 1000;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
spent(): { usage: Usage; turns: number; seconds: number } {
|
|
38
|
+
return { usage: this.usage, turns: this.turns, seconds: this.elapsedSeconds() };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Returns the first limit that has been reached, or null if within budget. */
|
|
42
|
+
exceeded(): BudgetLimit | null {
|
|
43
|
+
const b = this.budget;
|
|
44
|
+
if (b.tokens !== undefined && this.usage.inputTokens + this.usage.outputTokens >= b.tokens) {
|
|
45
|
+
return "tokens";
|
|
46
|
+
}
|
|
47
|
+
if (b.usd !== undefined && this.usage.usd >= b.usd) return "usd";
|
|
48
|
+
if (b.turns !== undefined && this.turns >= b.turns) return "turns";
|
|
49
|
+
if (b.seconds !== undefined && this.elapsedSeconds() >= b.seconds) return "seconds";
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
remaining(): Budget {
|
|
54
|
+
const b = this.budget;
|
|
55
|
+
const out: Budget = {};
|
|
56
|
+
if (b.tokens !== undefined) {
|
|
57
|
+
out.tokens = Math.max(0, b.tokens - (this.usage.inputTokens + this.usage.outputTokens));
|
|
58
|
+
}
|
|
59
|
+
if (b.usd !== undefined) out.usd = Math.max(0, b.usd - this.usage.usd);
|
|
60
|
+
if (b.turns !== undefined) out.turns = Math.max(0, b.turns - this.turns);
|
|
61
|
+
if (b.seconds !== undefined) out.seconds = Math.max(0, b.seconds - this.elapsedSeconds());
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import type { AuditSink, AuditEvent } from "./audit.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Wraps an AuditSink and re-emits each appended event as a structured object on
|
|
6
|
+
* an `EventEmitter` — the tap the HTTP service subscribes to for its SSE stream.
|
|
7
|
+
* Unlike `LoggingAudit` (which emits formatted strings for humans), this
|
|
8
|
+
* preserves the full `AuditEvent`. Persistence/querying delegate to the base.
|
|
9
|
+
*/
|
|
10
|
+
export class EmittingAudit extends EventEmitter implements AuditSink {
|
|
11
|
+
constructor(private readonly base: AuditSink) {
|
|
12
|
+
super();
|
|
13
|
+
this.setMaxListeners(0); // many SSE clients may subscribe
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async append(
|
|
17
|
+
type: string,
|
|
18
|
+
fields: Omit<Partial<AuditEvent>, "seq" | "at" | "type"> = {},
|
|
19
|
+
): Promise<AuditEvent> {
|
|
20
|
+
const event = await this.base.append(type, fields);
|
|
21
|
+
this.emit("event", event);
|
|
22
|
+
return event;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
load(): Promise<void> {
|
|
26
|
+
return this.base.load?.() ?? Promise.resolve();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
all(): readonly AuditEvent[] {
|
|
30
|
+
return this.base.all();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Proposal } from "../domain/types.js";
|
|
2
|
+
import type { AuditSink } from "./audit.js";
|
|
3
|
+
|
|
4
|
+
export interface ActionContext {
|
|
5
|
+
cwd: string;
|
|
6
|
+
nodeId: string;
|
|
7
|
+
runId?: string;
|
|
8
|
+
/** Team-memory scope for team-scoped executor writes (e.g. watchlist). */
|
|
9
|
+
managerNodeId?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ActionResult {
|
|
13
|
+
ok: boolean;
|
|
14
|
+
result?: unknown;
|
|
15
|
+
error?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ActionHandler = (input: unknown, ctx: ActionContext) => Promise<ActionResult>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Performs approved proposed actions deterministically — no model call. The
|
|
22
|
+
* other half of the async-gate model: agents *propose*, a human *approves*, and
|
|
23
|
+
* this executor *does* the thing. Actions are registered by name (the bare tool
|
|
24
|
+
* name an agent proposed, e.g. "deliver_to_client").
|
|
25
|
+
*/
|
|
26
|
+
export class ActionExecutor {
|
|
27
|
+
private readonly handlers = new Map<string, ActionHandler>();
|
|
28
|
+
|
|
29
|
+
constructor(private readonly audit: AuditSink) {}
|
|
30
|
+
|
|
31
|
+
register(action: string, handler: ActionHandler): this {
|
|
32
|
+
this.handlers.set(action, handler);
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
has(action: string): boolean {
|
|
37
|
+
return this.handlers.has(action);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Execute the action a proposal describes. Records the outcome to the audit log. */
|
|
41
|
+
async execute(proposal: Proposal): Promise<ActionResult> {
|
|
42
|
+
const handler = this.handlers.get(proposal.action);
|
|
43
|
+
if (!handler) {
|
|
44
|
+
const error = `no executor registered for action "${proposal.action}"`;
|
|
45
|
+
await this.audit.append("proposal.execute_failed", {
|
|
46
|
+
nodeId: proposal.nodeId,
|
|
47
|
+
...(proposal.runId !== undefined ? { runId: proposal.runId } : {}),
|
|
48
|
+
data: { proposalId: proposal.id, action: proposal.action, error },
|
|
49
|
+
});
|
|
50
|
+
return { ok: false, error };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ctx: ActionContext = {
|
|
54
|
+
cwd: proposal.cwd,
|
|
55
|
+
nodeId: proposal.nodeId,
|
|
56
|
+
...(proposal.runId !== undefined ? { runId: proposal.runId } : {}),
|
|
57
|
+
...(proposal.managerNodeId !== undefined ? { managerNodeId: proposal.managerNodeId } : {}),
|
|
58
|
+
};
|
|
59
|
+
let result: ActionResult;
|
|
60
|
+
try {
|
|
61
|
+
result = await handler(proposal.input, ctx);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
result = { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await this.audit.append(result.ok ? "proposal.executed" : "proposal.execute_failed", {
|
|
67
|
+
nodeId: proposal.nodeId,
|
|
68
|
+
...(proposal.runId !== undefined ? { runId: proposal.runId } : {}),
|
|
69
|
+
data: { proposalId: proposal.id, action: proposal.action, ...(result.error ? { error: result.error } : {}) },
|
|
70
|
+
});
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Instant stop control. The owner can halt one agent, a whole team (subtree),
|
|
3
|
+
* or the entire org. Each in-flight run registers an AbortController; killing a
|
|
4
|
+
* scope aborts every matching run and blocks new runs in that scope until it is
|
|
5
|
+
* cleared.
|
|
6
|
+
*
|
|
7
|
+
* Scope matching is by node-id prefix: scope `"sales"` kills `sales` and
|
|
8
|
+
* `sales/researcher`; scope `"*"` kills everything.
|
|
9
|
+
*/
|
|
10
|
+
export class KillSwitch {
|
|
11
|
+
private readonly active = new Map<string, Set<AbortController>>();
|
|
12
|
+
private readonly killedScopes = new Set<string>();
|
|
13
|
+
|
|
14
|
+
private matches(scope: string, nodeId: string): boolean {
|
|
15
|
+
if (scope === "*") return true;
|
|
16
|
+
return nodeId === scope || nodeId.startsWith(`${scope}/`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Is this node currently under an active kill scope? */
|
|
20
|
+
isKilled(nodeId: string): boolean {
|
|
21
|
+
for (const scope of this.killedScopes) {
|
|
22
|
+
if (this.matches(scope, nodeId)) return true;
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Register an in-flight run for a node. Returns an AbortController whose
|
|
29
|
+
* signal fires if the node's scope is (or becomes) killed. Caller must call
|
|
30
|
+
* `release` when the run ends.
|
|
31
|
+
*/
|
|
32
|
+
register(nodeId: string): AbortController {
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
if (this.isKilled(nodeId)) {
|
|
35
|
+
controller.abort(new Error(`node ${nodeId} is under an active kill`));
|
|
36
|
+
return controller;
|
|
37
|
+
}
|
|
38
|
+
let set = this.active.get(nodeId);
|
|
39
|
+
if (!set) {
|
|
40
|
+
set = new Set();
|
|
41
|
+
this.active.set(nodeId, set);
|
|
42
|
+
}
|
|
43
|
+
set.add(controller);
|
|
44
|
+
return controller;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
release(nodeId: string, controller: AbortController): void {
|
|
48
|
+
this.active.get(nodeId)?.delete(controller);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Halt a scope: abort all matching in-flight runs and block new ones. */
|
|
52
|
+
kill(scope: string): number {
|
|
53
|
+
this.killedScopes.add(scope);
|
|
54
|
+
let aborted = 0;
|
|
55
|
+
for (const [nodeId, set] of this.active) {
|
|
56
|
+
if (!this.matches(scope, nodeId)) continue;
|
|
57
|
+
for (const controller of set) {
|
|
58
|
+
controller.abort(new Error(`killed: scope ${scope}`));
|
|
59
|
+
aborted += 1;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return aborted;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Clear a kill scope so the node(s) can run again. */
|
|
66
|
+
clear(scope: string): void {
|
|
67
|
+
this.killedScopes.delete(scope);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
clearAll(): void {
|
|
71
|
+
this.killedScopes.clear();
|
|
72
|
+
}
|
|
73
|
+
}
|