@aragon-agent/cli 0.6.37 → 0.6.39
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 +2523 -2326
- package/README.md +3614 -3173
- package/dist/agent/controller.d.ts +142 -1
- package/dist/agent/controller.js +171 -3
- package/dist/agent/reducer.d.ts +41 -1
- package/dist/agent/reducer.js +25 -0
- package/dist/agent/system-prompt.d.ts +14 -0
- package/dist/agent/system-prompt.js +5 -0
- package/dist/cli.js +85 -27
- package/dist/commands/builtins.js +13 -1
- package/dist/commands/keys-command.d.ts +23 -0
- package/dist/commands/keys-command.js +38 -0
- package/dist/commands/perf.d.ts +7 -1
- package/dist/commands/perf.js +2 -2
- package/dist/commands/registry.d.ts +39 -0
- package/dist/commands/task-command.d.ts +39 -0
- package/dist/commands/task-command.js +102 -0
- package/dist/config/cli-commands.d.ts +23 -0
- package/dist/config/cli-commands.js +36 -0
- package/dist/config/env.js +17 -4
- package/dist/config/load.js +6 -1
- package/dist/config/schema.d.ts +43 -4
- package/dist/config/schema.js +31 -5
- package/dist/config/store.js +17 -2
- package/dist/diagnostics/doctor.d.ts +9 -0
- package/dist/diagnostics/doctor.js +34 -0
- package/dist/diagnostics/info.d.ts +71 -0
- package/dist/diagnostics/info.js +91 -5
- package/dist/exec/bridge/caller-tools.d.ts +96 -0
- package/dist/exec/bridge/caller-tools.js +213 -0
- package/dist/exec/bridge/decisions.d.ts +78 -0
- package/dist/exec/bridge/decisions.js +116 -0
- package/dist/exec/bridge/human-answers.d.ts +38 -0
- package/dist/exec/bridge/human-answers.js +164 -0
- package/dist/exec/bridge/limits.d.ts +68 -0
- package/dist/exec/bridge/limits.js +68 -0
- package/dist/exec/bridge/ndjson.d.ts +17 -0
- package/dist/exec/bridge/ndjson.js +83 -0
- package/dist/exec/bridge/pending.d.ts +70 -0
- package/dist/exec/bridge/pending.js +132 -0
- package/dist/exec/bridge/protocol.d.ts +346 -0
- package/dist/exec/bridge/protocol.js +256 -0
- package/dist/exec/bridge/server.d.ts +142 -0
- package/dist/exec/bridge/server.js +502 -0
- package/dist/exec/bridge/source.d.ts +58 -0
- package/dist/exec/bridge/source.js +145 -0
- package/dist/exec/bridge/writer.d.ts +26 -0
- package/dist/exec/bridge/writer.js +156 -0
- package/dist/exec/cli-commands.js +30 -2
- package/dist/exec/emitter.d.ts +16 -2
- package/dist/exec/emitter.js +26 -6
- package/dist/exec/events.d.ts +465 -10
- package/dist/exec/events.js +47 -1
- package/dist/exec/hooks/dispatch.d.ts +76 -0
- package/dist/exec/hooks/dispatch.js +169 -0
- package/dist/exec/hooks/index.d.ts +83 -0
- package/dist/exec/hooks/index.js +205 -0
- package/dist/exec/hooks/limits.d.ts +56 -0
- package/dist/exec/hooks/limits.js +49 -0
- package/dist/exec/hooks/registry.d.ts +49 -0
- package/dist/exec/hooks/registry.js +91 -0
- package/dist/exec/hooks/tool-wrapper.d.ts +94 -0
- package/dist/exec/hooks/tool-wrapper.js +154 -0
- package/dist/exec/hooks/turn.d.ts +61 -0
- package/dist/exec/hooks/turn.js +59 -0
- package/dist/exec/hooks/validate.d.ts +85 -0
- package/dist/exec/hooks/validate.js +310 -0
- package/dist/exec/index.d.ts +130 -4
- package/dist/exec/index.js +501 -10
- package/dist/exec/options.d.ts +124 -2
- package/dist/exec/options.js +413 -6
- package/dist/exec/permission.d.ts +9 -1
- package/dist/exec/permission.js +7 -1
- package/dist/exec/request/limits.d.ts +51 -0
- package/dist/exec/request/limits.js +49 -0
- package/dist/exec/request/scope.d.ts +106 -0
- package/dist/exec/request/scope.js +149 -0
- package/dist/exec/result/limits.d.ts +74 -0
- package/dist/exec/result/limits.js +72 -0
- package/dist/exec/result/prompt.d.ts +28 -0
- package/dist/exec/result/prompt.js +74 -0
- package/dist/exec/result/schema.d.ts +99 -0
- package/dist/exec/result/schema.js +464 -0
- package/dist/exec/result/sink.d.ts +56 -0
- package/dist/exec/result/sink.js +50 -0
- package/dist/exec/result/tool.d.ts +37 -0
- package/dist/exec/result/tool.js +118 -0
- package/dist/exec/runner.d.ts +395 -1
- package/dist/exec/runner.js +860 -8
- package/dist/exec/threads/aggregate.d.ts +34 -0
- package/dist/exec/threads/aggregate.js +30 -0
- package/dist/exec/threads/capacity.d.ts +23 -0
- package/dist/exec/threads/capacity.js +38 -0
- package/dist/exec/threads/dispatch-source.d.ts +60 -0
- package/dist/exec/threads/dispatch-source.js +106 -0
- package/dist/exec/threads/execute-threads.d.ts +53 -0
- package/dist/exec/threads/execute-threads.js +234 -0
- package/dist/exec/threads/gate.d.ts +42 -0
- package/dist/exec/threads/gate.js +81 -0
- package/dist/exec/threads/identity.d.ts +36 -0
- package/dist/exec/threads/identity.js +67 -0
- package/dist/exec/threads/interrupt.d.ts +29 -0
- package/dist/exec/threads/interrupt.js +79 -0
- package/dist/exec/threads/limits.d.ts +58 -0
- package/dist/exec/threads/limits.js +35 -0
- package/dist/exec/threads/outcome.d.ts +47 -0
- package/dist/exec/threads/outcome.js +26 -0
- package/dist/exec/threads/registry.d.ts +81 -0
- package/dist/exec/threads/registry.js +427 -0
- package/dist/exec/threads/runtime.d.ts +107 -0
- package/dist/exec/threads/runtime.js +523 -0
- package/dist/exec/threads/thread-source.d.ts +20 -0
- package/dist/exec/threads/thread-source.js +134 -0
- package/dist/fast/limits.d.ts +54 -0
- package/dist/fast/limits.js +54 -0
- package/dist/fast/summary-call.d.ts +72 -0
- package/dist/fast/summary-call.js +133 -0
- package/dist/fast/summary-prompt.d.ts +106 -0
- package/dist/fast/summary-prompt.js +320 -0
- package/dist/fast/task-summary-types.d.ts +66 -0
- package/dist/fast/task-summary-types.js +17 -0
- package/dist/logging/logger.d.ts +1 -1
- package/dist/proc/limits.d.ts +7 -0
- package/dist/proc/limits.js +7 -0
- package/dist/proc/output-streams.d.ts +50 -0
- package/dist/proc/output-streams.js +54 -0
- package/dist/team/runtime.d.ts +9 -0
- package/dist/team/runtime.js +8 -0
- package/dist/team/subagent.d.ts +15 -0
- package/dist/team/subagent.js +45 -1
- package/dist/tools/bash-tool.d.ts +10 -0
- package/dist/tools/bash-tool.js +71 -6
- package/dist/tools/index.d.ts +93 -1
- package/dist/tools/index.js +112 -3
- package/dist/tools/tool-gate.d.ts +100 -0
- package/dist/tools/tool-gate.js +87 -0
- package/dist/ui/App.js +224 -11
- package/dist/ui/Header.d.ts +28 -16
- package/dist/ui/Header.js +21 -19
- package/dist/ui/interaction-hints.js +6 -0
- package/dist/ui/layout/OverlayFrame.d.ts +18 -0
- package/dist/ui/layout/OverlayFrame.js +19 -4
- package/dist/ui/layout/header-hints.d.ts +55 -62
- package/dist/ui/layout/header-hints.js +59 -166
- package/dist/ui/layout/header-left.d.ts +115 -0
- package/dist/ui/layout/header-left.js +206 -0
- package/dist/ui/layout/shortcut-panel-layout.d.ts +191 -0
- package/dist/ui/layout/shortcut-panel-layout.js +321 -0
- package/dist/ui/layout/status-detail-layout.js +5 -1
- package/dist/ui/layout/task-summary-cell.d.ts +61 -0
- package/dist/ui/layout/task-summary-cell.js +65 -0
- package/dist/ui/overlays/HelpOverlay.d.ts +12 -0
- package/dist/ui/overlays/HelpOverlay.js +29 -2
- package/dist/ui/overlays/ShortcutPanel.d.ts +39 -0
- package/dist/ui/overlays/ShortcutPanel.js +106 -0
- package/dist/ui/scrollback/canvas.d.ts +3 -2
- package/dist/ui/scrollback/canvas.js +3 -2
- package/dist/ui/scrollback/scrollback-writer.d.ts +13 -1
- package/dist/ui/scrollback/scrollback-writer.js +109 -10
- package/dist/ui/shortcuts.d.ts +81 -0
- package/dist/ui/shortcuts.js +221 -0
- package/package.json +2 -2
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bridge server: one multiplexer for both directions of the channel.
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* WHAT IT OWNS
|
|
7
|
+
*
|
|
8
|
+
* - the pending table (ids, deadlines, settle-once, cancellation),
|
|
9
|
+
* - the outbound frames that are NOT exec events (`decision_request`,
|
|
10
|
+
* `tool_invoke`, `tool_cancel`, `ack`, `pong`),
|
|
11
|
+
* - the caller-tool registry (validation and the turn-boundary write),
|
|
12
|
+
* - the open/closed state every gate reads as `isOpen()`.
|
|
13
|
+
*
|
|
14
|
+
* WHAT IT DOES NOT OWN. It never prompts the model, never advances a turn and
|
|
15
|
+
* never writes `result`: the runner does the first two, `exec/index.ts` the third.
|
|
16
|
+
* The consequence worth stating is that `close()` can be called from any exit path
|
|
17
|
+
* without any of those owners being consulted.
|
|
18
|
+
*
|
|
19
|
+
* THE ONE ASYMMETRY THAT MATTERS. `requestDecision` resolves; it never rejects and
|
|
20
|
+
* never throws, even for a frame that arrived mangled. A gate is therefore allowed
|
|
21
|
+
* to `await` it without a `try`, and the fail-closed answer for anything untrusted
|
|
22
|
+
* is the same one for a timeout: "not allowed", with the reason written where the
|
|
23
|
+
* model cannot see it and the caller can.
|
|
24
|
+
*/
|
|
25
|
+
import { type ToolResult } from '@aragon-agent/core';
|
|
26
|
+
import { type AgentScope, type BridgeInputFrame, type CallerToolCall, type CallerToolSpec, type DecisionAnswer, type DecisionAsk, type DecisionKind, type DecisionOutcome } from './protocol.js';
|
|
27
|
+
import type { BridgeWriter } from './writer.js';
|
|
28
|
+
/** What the host must provide for the frames that change host state. */
|
|
29
|
+
export interface BridgeServerPorts {
|
|
30
|
+
/** Replace the caller-served tool set. ONLY called at a turn boundary. */
|
|
31
|
+
applyCallerTools: (tools: CallerToolSpec[]) => void;
|
|
32
|
+
/** True while `agent.state.isRunning` - registration must wait. */
|
|
33
|
+
isTurnActive: () => boolean;
|
|
34
|
+
/** The names the registry holds RIGHT NOW (the host's view of the union). */
|
|
35
|
+
callerToolNames: () => string[];
|
|
36
|
+
/** `'plan'` refuses `tool_register` outright. */
|
|
37
|
+
getAgentMode: () => 'build' | 'plan';
|
|
38
|
+
}
|
|
39
|
+
export interface BridgeTurnPorts {
|
|
40
|
+
/** `steer` frame: enqueue guidance, or refuse when the agent is idle. */
|
|
41
|
+
steer: (text: string, id: string | undefined) => void;
|
|
42
|
+
/** `interrupt` frame: cancel a queued turn, or abort the one in flight. */
|
|
43
|
+
interrupt: (requestId: string | undefined, id: string | undefined) => void;
|
|
44
|
+
/**
|
|
45
|
+
* `cancel_request` frame: stop ONE request and keep the process serving.
|
|
46
|
+
*
|
|
47
|
+
* The same two arguments as `interrupt` on purpose - the frame shapes are identical
|
|
48
|
+
* and the meaning is what differs - so an implementer cannot accidentally wire one
|
|
49
|
+
* into the other's slot without the types noticing.
|
|
50
|
+
*/
|
|
51
|
+
cancelRequest: (requestId: string | undefined, id: string | undefined) => void;
|
|
52
|
+
}
|
|
53
|
+
export interface BridgeAuditRecord {
|
|
54
|
+
requestId: string;
|
|
55
|
+
kind: DecisionKind | 'caller_tool' | 'hook';
|
|
56
|
+
outcome: string;
|
|
57
|
+
tool?: string;
|
|
58
|
+
reason?: string;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The lifecycle-hook channel, as the server sees it
|
|
62
|
+
* (headless-threads-and-lifecycle-hooks section 2.4.2).
|
|
63
|
+
*
|
|
64
|
+
* WHY THE SERVER AND NOT THE SOURCE: `bridge/source.ts` routes every non-turn frame to
|
|
65
|
+
* `server.handleFrame` and is on this round's UNTOUCHED list, so the two hook frames have
|
|
66
|
+
* to be understood here. The server owns no hook state - it validates nothing, times
|
|
67
|
+
* nothing out and writes nothing on its own - which is what keeps this a two-case
|
|
68
|
+
* addition rather than a second dispatcher.
|
|
69
|
+
*/
|
|
70
|
+
export interface BridgeHookPorts {
|
|
71
|
+
/** `hook_register`: answer with the ids that are now live, or a refusal. */
|
|
72
|
+
register: (frame: Extract<BridgeInputFrame, {
|
|
73
|
+
type: 'hook_register';
|
|
74
|
+
}>) => void;
|
|
75
|
+
/**
|
|
76
|
+
* `hook_answer`: `true` when it named a live request. The port writes the ack itself
|
|
77
|
+
* when it returns `false`, because only it knows which code applies
|
|
78
|
+
* (`hook_not_registered`) and whether the frame even belongs to this channel.
|
|
79
|
+
*/
|
|
80
|
+
answer: (frame: Extract<BridgeInputFrame, {
|
|
81
|
+
type: 'hook_answer';
|
|
82
|
+
}>) => boolean;
|
|
83
|
+
}
|
|
84
|
+
export interface BridgeServerOptions {
|
|
85
|
+
writer: BridgeWriter;
|
|
86
|
+
sessionId: string;
|
|
87
|
+
now?: () => number;
|
|
88
|
+
decisionTimeoutMs: number;
|
|
89
|
+
callerToolTimeoutMs: number;
|
|
90
|
+
ports: BridgeServerPorts;
|
|
91
|
+
/** Names a caller tool may not shadow. Defaults to host + subagent tool names. */
|
|
92
|
+
reservedToolNames?: ReadonlySet<string>;
|
|
93
|
+
/**
|
|
94
|
+
* The one structured audit line per decision (headless-host-bridge section 2.5).
|
|
95
|
+
*
|
|
96
|
+
* A CALLBACK RATHER THAN A LOGGER IMPORT, so this module stays testable without
|
|
97
|
+
* process state and so `exec/index.ts` remains the only place that decides what
|
|
98
|
+
* "logged" means.
|
|
99
|
+
*/
|
|
100
|
+
onAudit?: (record: BridgeAuditRecord) => void;
|
|
101
|
+
/** A non-fatal protocol problem, for the same reason. */
|
|
102
|
+
onProtocolError?: (code: string, message: string) => void;
|
|
103
|
+
/**
|
|
104
|
+
* The hook channel, present only when this server is the `hook_register` destination
|
|
105
|
+
* (bridge mode; every thread's own server in thread mode).
|
|
106
|
+
*
|
|
107
|
+
* ABSENT MEANS THE FRAMES ARE REFUSED, not ignored: a caller that sent `hook_register` to
|
|
108
|
+
* a run without the channel must be told (`hook_requires_bridge`), because silence reads
|
|
109
|
+
* as "registered" and the next symptom is a redactor that never fires.
|
|
110
|
+
*/
|
|
111
|
+
hookPorts?: BridgeHookPorts;
|
|
112
|
+
}
|
|
113
|
+
export interface BridgeServer {
|
|
114
|
+
isOpen(): boolean;
|
|
115
|
+
/** Resolves with a discriminated outcome; NEVER rejects. */
|
|
116
|
+
requestDecision(ask: DecisionAsk, signal?: AbortSignal, validate?: (answer: DecisionAnswer) => boolean): Promise<DecisionOutcome>;
|
|
117
|
+
invokeCallerTool(call: CallerToolCall, signal?: AbortSignal): Promise<ToolResult>;
|
|
118
|
+
/** Route one control frame. Turn frames are the source's business. */
|
|
119
|
+
handleFrame(frame: BridgeInputFrame): void;
|
|
120
|
+
/** Soft-limit backpressure, awaited at turn boundaries only. */
|
|
121
|
+
drainIfNeeded(): Promise<void>;
|
|
122
|
+
/** Settle everything, flush, and refuse all later frames. */
|
|
123
|
+
close(reason: 'run_finished' | 'caller_eof' | 'hard_limit'): Promise<void>;
|
|
124
|
+
/** Settle every unanswered decision as `aborted`; used on interrupt and EOF. */
|
|
125
|
+
abortInFlight(): void;
|
|
126
|
+
/** Names currently registered, for `ack.tools` and `info`. */
|
|
127
|
+
callerToolNames(): string[];
|
|
128
|
+
/** Write one outbound frame through the sealed writer. */
|
|
129
|
+
write(event: Record<string, unknown>): void;
|
|
130
|
+
/**
|
|
131
|
+
* Called by the runner as a turn begins: records the turn number and flushes a
|
|
132
|
+
* deferred `tool_register`. THE ONLY PLACE THE REGISTRY IS WRITTEN, which is what
|
|
133
|
+
* makes I-8 a structural fact rather than a promise.
|
|
134
|
+
*/
|
|
135
|
+
beginTurn(turn: number): void;
|
|
136
|
+
currentTurn(): number;
|
|
137
|
+
/** Installed by `runner.attachBridge`; before that the frames are refused. */
|
|
138
|
+
attachTurnPorts(ports: BridgeTurnPorts): void;
|
|
139
|
+
}
|
|
140
|
+
export declare function createBridgeServer(options: BridgeServerOptions): BridgeServer;
|
|
141
|
+
/** Re-exported so `decisions.ts` and the tests read one name. */
|
|
142
|
+
export type { AgentScope };
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bridge server: one multiplexer for both directions of the channel.
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* WHAT IT OWNS
|
|
7
|
+
*
|
|
8
|
+
* - the pending table (ids, deadlines, settle-once, cancellation),
|
|
9
|
+
* - the outbound frames that are NOT exec events (`decision_request`,
|
|
10
|
+
* `tool_invoke`, `tool_cancel`, `ack`, `pong`),
|
|
11
|
+
* - the caller-tool registry (validation and the turn-boundary write),
|
|
12
|
+
* - the open/closed state every gate reads as `isOpen()`.
|
|
13
|
+
*
|
|
14
|
+
* WHAT IT DOES NOT OWN. It never prompts the model, never advances a turn and
|
|
15
|
+
* never writes `result`: the runner does the first two, `exec/index.ts` the third.
|
|
16
|
+
* The consequence worth stating is that `close()` can be called from any exit path
|
|
17
|
+
* without any of those owners being consulted.
|
|
18
|
+
*
|
|
19
|
+
* THE ONE ASYMMETRY THAT MATTERS. `requestDecision` resolves; it never rejects and
|
|
20
|
+
* never throws, even for a frame that arrived mangled. A gate is therefore allowed
|
|
21
|
+
* to `await` it without a `try`, and the fail-closed answer for anything untrusted
|
|
22
|
+
* is the same one for a timeout: "not allowed", with the reason written where the
|
|
23
|
+
* model cannot see it and the caller can.
|
|
24
|
+
*/
|
|
25
|
+
import { errorResult } from '@aragon-agent/core';
|
|
26
|
+
import { HOST_TOOL_NAMES } from '../../tools/index.js';
|
|
27
|
+
import { TEAM_SUBAGENT_TOOL_NAMES } from '../../team/limits.js';
|
|
28
|
+
import { BRIDGE_LIMITS } from './limits.js';
|
|
29
|
+
import { createPendingTable } from './pending.js';
|
|
30
|
+
import { LEAD_SCOPE, } from './protocol.js';
|
|
31
|
+
import { UnsupportedContentError, callerToolResult, validateCallerTools, } from './caller-tools.js';
|
|
32
|
+
export function createBridgeServer(options) {
|
|
33
|
+
const now = options.now ?? Date.now;
|
|
34
|
+
const sessionId = options.sessionId;
|
|
35
|
+
const writer = options.writer;
|
|
36
|
+
const pending = createPendingTable({ now });
|
|
37
|
+
const reserved = options.reservedToolNames ??
|
|
38
|
+
new Set([...HOST_TOOL_NAMES, ...TEAM_SUBAGENT_TOOL_NAMES]);
|
|
39
|
+
let open = true;
|
|
40
|
+
let turn = 0;
|
|
41
|
+
let turnPorts = null;
|
|
42
|
+
/** A validated set waiting for the next turn boundary (I-8). */
|
|
43
|
+
let deferredSpecs = null;
|
|
44
|
+
const write = (event) => writer.write(event);
|
|
45
|
+
const protocolError = (code, message) => {
|
|
46
|
+
write({ type: 'error', sessionId, fatal: false, code, message });
|
|
47
|
+
options.onProtocolError?.(code, message);
|
|
48
|
+
};
|
|
49
|
+
const audit = (record) => {
|
|
50
|
+
options.onAudit?.(record);
|
|
51
|
+
};
|
|
52
|
+
const isTurnActive = () => options.ports.isTurnActive();
|
|
53
|
+
const register = (specs, id) => {
|
|
54
|
+
const names = specs.map((spec) => spec.name);
|
|
55
|
+
if (isTurnActive()) {
|
|
56
|
+
// DEFERRED, AND THE ACK SAYS SO. Writing the live registry mid-turn is the
|
|
57
|
+
// hazard `submit_plan` already taught this codebase once: the engine is
|
|
58
|
+
// iterating it. `appliedAtTurn` is the promise that it will be there for the
|
|
59
|
+
// next request.
|
|
60
|
+
deferredSpecs = specs;
|
|
61
|
+
write({
|
|
62
|
+
type: 'ack',
|
|
63
|
+
...(id ? { id } : {}),
|
|
64
|
+
accepted: true,
|
|
65
|
+
scope: 'lead',
|
|
66
|
+
tools: names,
|
|
67
|
+
appliedAtTurn: turn + 1,
|
|
68
|
+
});
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
options.ports.applyCallerTools(specs);
|
|
72
|
+
write({
|
|
73
|
+
type: 'ack',
|
|
74
|
+
...(id ? { id } : {}),
|
|
75
|
+
// `turn + 1` IN BOTH BRANCHES, so the field means one thing: "the earliest
|
|
76
|
+
// turn whose LLM request can see these tools". Before the first turn that is
|
|
77
|
+
// turn 1 (not 0, which reads as "a turn that never happened"), between turns
|
|
78
|
+
// it is the next one, and mid-turn it is the one after the current.
|
|
79
|
+
accepted: true,
|
|
80
|
+
scope: 'lead',
|
|
81
|
+
tools: names,
|
|
82
|
+
appliedAtTurn: turn + 1,
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
const handleToolRegister = (frame) => {
|
|
86
|
+
if (options.ports.getAgentMode() === 'plan') {
|
|
87
|
+
// WHOLE-FRAME REFUSAL, not "register and refuse at call time": plan mode's
|
|
88
|
+
// promise is read-only, and a caller tool's side effects are unknown to this
|
|
89
|
+
// process. Registering one would open an arbitrary hole in that promise.
|
|
90
|
+
write({
|
|
91
|
+
type: 'ack',
|
|
92
|
+
...(frame.id ? { id: frame.id } : {}),
|
|
93
|
+
accepted: false,
|
|
94
|
+
code: 'plan_mode_read_only',
|
|
95
|
+
message: 'Plan mode refuses caller tools; send this frame again in build mode.',
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const validated = validateCallerTools(frame.tools, reserved);
|
|
100
|
+
if (!validated.ok) {
|
|
101
|
+
write({
|
|
102
|
+
type: 'ack',
|
|
103
|
+
...(frame.id ? { id: frame.id } : {}),
|
|
104
|
+
accepted: false,
|
|
105
|
+
code: validated.code,
|
|
106
|
+
message: validated.message,
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
register(validated.specs, frame.id);
|
|
111
|
+
if (validated.warnings.length > 0) {
|
|
112
|
+
write({
|
|
113
|
+
type: 'ack',
|
|
114
|
+
...(frame.id ? { id: frame.id } : {}),
|
|
115
|
+
accepted: true,
|
|
116
|
+
scope: 'lead',
|
|
117
|
+
tools: validated.specs.map((spec) => spec.name),
|
|
118
|
+
warnings: validated.warnings,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const handleDecisionFrame = (frame) => {
|
|
123
|
+
const kind = pending.kindOf(frame.respondsTo);
|
|
124
|
+
if (kind === undefined) {
|
|
125
|
+
reportMissing(frame.respondsTo);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (kind !== 'decision') {
|
|
129
|
+
// THE OTHER TABLE'S QUESTION. Answering it here would settle a `tool_invoke`
|
|
130
|
+
// with a decision payload, so the entry must survive (AC-55).
|
|
131
|
+
protocolError('bridge_kind_mismatch', `"${frame.respondsTo}" awaits a caller tool result, not a decision.`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
reportAnswer(frame.respondsTo, 'decision', frame);
|
|
135
|
+
};
|
|
136
|
+
const handleToolResultFrame = (frame) => {
|
|
137
|
+
const kind = pending.kindOf(frame.respondsTo);
|
|
138
|
+
if (kind === undefined) {
|
|
139
|
+
reportMissing(frame.respondsTo);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (kind !== 'caller_tool') {
|
|
143
|
+
protocolError('bridge_kind_mismatch', `"${frame.respondsTo}" awaits a decision, not a caller tool result.`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
reportAnswer(frame.respondsTo, 'caller_tool', {
|
|
147
|
+
content: frame.content,
|
|
148
|
+
isError: frame.isError === true,
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* An answer that matched no live entry is ONE OF TWO THINGS, and the codes
|
|
153
|
+
* differ (section 2.3.8 rows 4 and 5): answered twice, or answered too late /
|
|
154
|
+
* never asked. The pending table remembers only the first kind, which is why it
|
|
155
|
+
* has to be asked rather than assumed.
|
|
156
|
+
*/
|
|
157
|
+
const reportMissing = (requestId) => {
|
|
158
|
+
if (pending.wasAnswered(requestId)) {
|
|
159
|
+
protocolError('bridge_duplicate_response', `"${requestId}" was already answered.`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
protocolError('bridge_unknown_request', `"${requestId}" is not an outstanding request.`);
|
|
163
|
+
};
|
|
164
|
+
const reportAnswer = (requestId, kind, value) => {
|
|
165
|
+
// A live entry of the WRONG kind never reaches here: the two frame handlers
|
|
166
|
+
// reject it above and leave the entry pending (AC-55).
|
|
167
|
+
if (pending.answer(requestId, kind, value) === 'ok')
|
|
168
|
+
return;
|
|
169
|
+
reportMissing(requestId);
|
|
170
|
+
};
|
|
171
|
+
const decisionFrame = (requestId, ask, createdAt, deadlineAt) => {
|
|
172
|
+
return {
|
|
173
|
+
type: 'decision_request',
|
|
174
|
+
requestId,
|
|
175
|
+
sessionId,
|
|
176
|
+
kind: ask.kind,
|
|
177
|
+
createdAt,
|
|
178
|
+
deadlineAt,
|
|
179
|
+
timeoutMs: options.decisionTimeoutMs,
|
|
180
|
+
...(ask.kind === 'tool' ? { tool: { ...ask.tool, turn: ask.tool.turn || turn } } : {}),
|
|
181
|
+
...(ask.kind === 'questions' ? { questions: ask.questions } : {}),
|
|
182
|
+
...(ask.kind === 'plan' ? { plan: ask.plan } : {}),
|
|
183
|
+
...(ask.kind === 'skill_install' ? { install: ask.install } : {}),
|
|
184
|
+
agent: ask.agent ?? LEAD_SCOPE,
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
return {
|
|
188
|
+
isOpen: () => open,
|
|
189
|
+
write,
|
|
190
|
+
beginTurn(nextTurn) {
|
|
191
|
+
turn = nextTurn;
|
|
192
|
+
if (!deferredSpecs)
|
|
193
|
+
return;
|
|
194
|
+
const specs = deferredSpecs;
|
|
195
|
+
deferredSpecs = null;
|
|
196
|
+
options.ports.applyCallerTools(specs);
|
|
197
|
+
},
|
|
198
|
+
currentTurn: () => turn,
|
|
199
|
+
attachTurnPorts(ports) {
|
|
200
|
+
turnPorts = ports;
|
|
201
|
+
},
|
|
202
|
+
callerToolNames() {
|
|
203
|
+
// A DEFERRED SET IS THE HONEST ANSWER: it is what the model will be able to
|
|
204
|
+
// call from the next turn, and `ack.appliedAtTurn` already told the caller
|
|
205
|
+
// when. Reporting the older names would make the ack contradict itself.
|
|
206
|
+
const deferred = deferredSpecs;
|
|
207
|
+
return deferred ? deferred.map((spec) => spec.name) : options.ports.callerToolNames();
|
|
208
|
+
},
|
|
209
|
+
async requestDecision(ask, signal, validate) {
|
|
210
|
+
if (!open)
|
|
211
|
+
return { outcome: 'closed', reason: 'server_closed' };
|
|
212
|
+
const handle = pending.create({
|
|
213
|
+
kind: 'decision',
|
|
214
|
+
timeoutMs: options.decisionTimeoutMs,
|
|
215
|
+
...(signal ? { signal } : {}),
|
|
216
|
+
meta: {
|
|
217
|
+
...(ask.kind === 'tool' ? { tool: ask.tool.name, callId: ask.tool.callId } : {}),
|
|
218
|
+
...(ask.agent ? { agent: ask.agent } : {}),
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
if (!handle) {
|
|
222
|
+
// IMMEDIATE REFUSAL, NOT A QUEUE. Queueing would make a three-way fan-out
|
|
223
|
+
// look frozen; refusing is explainable and the model can route around it.
|
|
224
|
+
protocolError('bridge_decision_capacity', `More than ${BRIDGE_LIMITS.maxOutstandingDecisions} decisions are awaiting an answer.`);
|
|
225
|
+
audit({ requestId: '-', kind: ask.kind, outcome: 'deny', reason: 'decision_capacity' });
|
|
226
|
+
return { outcome: 'answered', answer: { decision: 'deny', reason: 'decision_capacity' } };
|
|
227
|
+
}
|
|
228
|
+
write(decisionFrame(handle.requestId, ask, handle.createdAt, handle.deadlineAt));
|
|
229
|
+
const settled = await handle.settled;
|
|
230
|
+
const answer = settled.outcome === 'settled'
|
|
231
|
+
? clipAnswerReason(settled.value)
|
|
232
|
+
: undefined;
|
|
233
|
+
const rejected = answer !== undefined && validate !== undefined && !validate(answer);
|
|
234
|
+
const outcome = resolvedOutcome(settled.outcome, answer, rejected);
|
|
235
|
+
write({
|
|
236
|
+
type: 'decision_resolved',
|
|
237
|
+
requestId: handle.requestId,
|
|
238
|
+
sessionId,
|
|
239
|
+
kind: ask.kind,
|
|
240
|
+
outcome,
|
|
241
|
+
...(answer?.scope ? { scope: answer.scope } : {}),
|
|
242
|
+
...(answer?.reason && outcome === 'deny' ? { reason: clipReason(answer.reason) } : {}),
|
|
243
|
+
});
|
|
244
|
+
if (rejected) {
|
|
245
|
+
protocolError('bridge_frame_invalid', `The answer to "${handle.requestId}" was not a valid ${ask.kind} decision.`);
|
|
246
|
+
}
|
|
247
|
+
audit({
|
|
248
|
+
requestId: handle.requestId,
|
|
249
|
+
kind: ask.kind,
|
|
250
|
+
outcome,
|
|
251
|
+
...(ask.kind === 'tool' ? { tool: ask.tool.name } : {}),
|
|
252
|
+
...(answer?.reason ? { reason: clipReason(answer.reason) } : {}),
|
|
253
|
+
});
|
|
254
|
+
if (settled.outcome === 'settled') {
|
|
255
|
+
return { outcome: 'answered', answer: answer ?? {} };
|
|
256
|
+
}
|
|
257
|
+
if (settled.outcome === 'timeout') {
|
|
258
|
+
return { outcome: 'timeout', deadlineAt: settled.deadlineAt };
|
|
259
|
+
}
|
|
260
|
+
if (settled.outcome === 'aborted')
|
|
261
|
+
return { outcome: 'aborted' };
|
|
262
|
+
return { outcome: 'closed', reason: settled.reason };
|
|
263
|
+
},
|
|
264
|
+
async invokeCallerTool(call, signal) {
|
|
265
|
+
if (!open)
|
|
266
|
+
return errorResult('The caller channel is closed; this tool was not run.');
|
|
267
|
+
const handle = pending.create({
|
|
268
|
+
kind: 'caller_tool',
|
|
269
|
+
timeoutMs: call.timeoutMs,
|
|
270
|
+
...(signal ? { signal } : {}),
|
|
271
|
+
meta: { tool: call.tool, callId: call.callId, agent: call.agent },
|
|
272
|
+
});
|
|
273
|
+
if (!handle) {
|
|
274
|
+
protocolError('bridge_caller_capacity', `More than ${BRIDGE_LIMITS.maxOutstandingCallerTools} caller tool calls are in flight.`);
|
|
275
|
+
return errorResult(`Caller tool "${call.tool}" was not run: too many caller tool calls are in flight.`);
|
|
276
|
+
}
|
|
277
|
+
write({
|
|
278
|
+
type: 'tool_invoke',
|
|
279
|
+
requestId: handle.requestId,
|
|
280
|
+
sessionId,
|
|
281
|
+
callId: call.callId,
|
|
282
|
+
turn: call.turn,
|
|
283
|
+
tool: call.tool,
|
|
284
|
+
input: truncateInput(call.input),
|
|
285
|
+
timeoutMs: call.timeoutMs,
|
|
286
|
+
deadlineAt: handle.deadlineAt,
|
|
287
|
+
agent: call.agent,
|
|
288
|
+
});
|
|
289
|
+
const settled = await handle.settled;
|
|
290
|
+
if (settled.outcome === 'settled') {
|
|
291
|
+
const built = buildCallerToolResult(call, settled.value, protocolError);
|
|
292
|
+
return built;
|
|
293
|
+
}
|
|
294
|
+
if (settled.outcome === 'timeout') {
|
|
295
|
+
write({
|
|
296
|
+
type: 'tool_cancel',
|
|
297
|
+
requestId: handle.requestId,
|
|
298
|
+
sessionId,
|
|
299
|
+
reason: 'timeout',
|
|
300
|
+
});
|
|
301
|
+
protocolError('caller_tool_timeout', `Caller tool "${call.tool}" did not answer within ${call.timeoutMs} ms.`);
|
|
302
|
+
return errorResult(`Caller tool "${call.tool}" did not answer within ${call.timeoutMs} ms.`);
|
|
303
|
+
}
|
|
304
|
+
if (settled.outcome === 'aborted') {
|
|
305
|
+
// MANDATORY, NOT DEFENSIVE: an unsettled caller tool is a `tool_use` with
|
|
306
|
+
// no `tool_result`, and the next provider request is an HTTP 400.
|
|
307
|
+
write({
|
|
308
|
+
type: 'tool_cancel',
|
|
309
|
+
requestId: handle.requestId,
|
|
310
|
+
sessionId,
|
|
311
|
+
reason: 'turn_aborted',
|
|
312
|
+
});
|
|
313
|
+
return errorResult(`Caller tool "${call.tool}" was cancelled: the turn was interrupted.`);
|
|
314
|
+
}
|
|
315
|
+
return errorResult(`Caller tool "${call.tool}" was cancelled: the caller channel closed.`);
|
|
316
|
+
},
|
|
317
|
+
handleFrame(frame) {
|
|
318
|
+
if (!open)
|
|
319
|
+
return;
|
|
320
|
+
switch (frame.type) {
|
|
321
|
+
case 'decision':
|
|
322
|
+
handleDecisionFrame(frame);
|
|
323
|
+
return;
|
|
324
|
+
case 'tool_result':
|
|
325
|
+
handleToolResultFrame(frame);
|
|
326
|
+
return;
|
|
327
|
+
case 'tool_register':
|
|
328
|
+
handleToolRegister(frame);
|
|
329
|
+
return;
|
|
330
|
+
case 'ping':
|
|
331
|
+
write({ type: 'pong', ...(frame.id ? { id: frame.id } : {}), now: now() });
|
|
332
|
+
return;
|
|
333
|
+
case 'steer':
|
|
334
|
+
if (turnPorts)
|
|
335
|
+
turnPorts.steer(frame.text, frame.id);
|
|
336
|
+
else {
|
|
337
|
+
write({
|
|
338
|
+
type: 'ack',
|
|
339
|
+
...(frame.id ? { id: frame.id } : {}),
|
|
340
|
+
accepted: false,
|
|
341
|
+
code: 'not_running',
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
return;
|
|
345
|
+
case 'interrupt':
|
|
346
|
+
if (turnPorts)
|
|
347
|
+
turnPorts.interrupt(frame.requestId, frame.id);
|
|
348
|
+
return;
|
|
349
|
+
case 'cancel_request':
|
|
350
|
+
// NO FALLBACK `ack` WHEN THE PORTS ARE MISSING, unlike `steer`: a frame that
|
|
351
|
+
// arrives before `runner.attachBridge` names a request that cannot be in
|
|
352
|
+
// flight yet, and inventing a refusal for it would be a lie about a state
|
|
353
|
+
// that does not exist. The requester re-sends or times out - the same
|
|
354
|
+
// contract `interrupt` already has.
|
|
355
|
+
if (turnPorts)
|
|
356
|
+
turnPorts.cancelRequest(frame.requestId, frame.id);
|
|
357
|
+
return;
|
|
358
|
+
// THE THREE THREAD FRAMES, REFUSED WHERE THEY CANNOT BE ROUTED (AC-2).
|
|
359
|
+
//
|
|
360
|
+
// In thread mode they never reach this switch: `dispatch-source.ts` routes
|
|
361
|
+
// `thread_open`/`thread_close`/`thread_list` to the registry, which owns the state
|
|
362
|
+
// machine. So a thread frame arriving HERE means this run did not open `--threads`,
|
|
363
|
+
// and the answer must be a receipt rather than the `default` branch's silence - a
|
|
364
|
+
// wrapper that believed it had opened a thread would send prompts into `main` and
|
|
365
|
+
// receive answers to a conversation it never asked for.
|
|
366
|
+
case 'thread_open':
|
|
367
|
+
case 'thread_close':
|
|
368
|
+
case 'thread_list':
|
|
369
|
+
write({
|
|
370
|
+
type: 'ack',
|
|
371
|
+
...(frame.id ? { id: frame.id } : {}),
|
|
372
|
+
accepted: false,
|
|
373
|
+
code: 'threads_disabled',
|
|
374
|
+
message: 'Threads are not enabled; start the run with --threads.',
|
|
375
|
+
});
|
|
376
|
+
return;
|
|
377
|
+
case 'hook_register':
|
|
378
|
+
if (!options.hookPorts) {
|
|
379
|
+
write({
|
|
380
|
+
type: 'ack',
|
|
381
|
+
...(frame.id ? { id: frame.id } : {}),
|
|
382
|
+
accepted: false,
|
|
383
|
+
code: 'hook_requires_bridge',
|
|
384
|
+
message: 'Lifecycle hooks need --input-format bridge; this run has no hook channel.',
|
|
385
|
+
});
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
options.hookPorts.register(frame);
|
|
389
|
+
return;
|
|
390
|
+
case 'hook_answer':
|
|
391
|
+
if (!options.hookPorts) {
|
|
392
|
+
write({
|
|
393
|
+
type: 'ack',
|
|
394
|
+
...(frame.id ? { id: frame.id } : {}),
|
|
395
|
+
accepted: false,
|
|
396
|
+
code: 'hook_requires_bridge',
|
|
397
|
+
message: 'Lifecycle hooks need --input-format bridge; this run has no hook channel.',
|
|
398
|
+
});
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
// THE ANSWER IS NOT ACKED ON SUCCESS. `hook_answer` is an answer to an answer:
|
|
402
|
+
// the fact the caller needs is `hook_resolved`, which the dispatcher writes for
|
|
403
|
+
// the exact id. A second ack would be a third frame saying the same thing.
|
|
404
|
+
if (!options.hookPorts.answer(frame)) {
|
|
405
|
+
write({
|
|
406
|
+
type: 'ack',
|
|
407
|
+
...(frame.id ? { id: frame.id } : {}),
|
|
408
|
+
accepted: false,
|
|
409
|
+
code: 'hook_not_registered',
|
|
410
|
+
message: `"${frame.respondsTo}" is not an outstanding hook request.`,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
return;
|
|
414
|
+
default:
|
|
415
|
+
// `prompt` / `user` / `end` belong to the source; an unknown type is
|
|
416
|
+
// ignored by the parser long before it reaches this switch.
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
async drainIfNeeded() {
|
|
421
|
+
await writer.drainIfNeeded();
|
|
422
|
+
},
|
|
423
|
+
abortInFlight() {
|
|
424
|
+
// NO `tool_cancel` IS WRITTEN HERE, DELIBERATELY. Every in-flight caller
|
|
425
|
+
// tool is awaiting its own `handle.settled`, and that continuation already
|
|
426
|
+
// writes exactly one `tool_cancel{turn_aborted}` when it observes `aborted`
|
|
427
|
+
// (see `invokeCallerTool`). Emitting one here as well would put the same
|
|
428
|
+
// frame on the wire twice for one requestId, and a caller that tracks
|
|
429
|
+
// outstanding calls by id would count it twice.
|
|
430
|
+
pending.abortAll();
|
|
431
|
+
},
|
|
432
|
+
async close(reason) {
|
|
433
|
+
if (!open)
|
|
434
|
+
return;
|
|
435
|
+
open = false;
|
|
436
|
+
pending.closeAll(reason);
|
|
437
|
+
await writer.flush();
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
/** `allow` / `deny` / `error` for a settled decision; the frame's own outcome otherwise. */
|
|
442
|
+
function resolvedOutcome(settled, answer, rejected) {
|
|
443
|
+
if (settled === 'timeout')
|
|
444
|
+
return 'timeout';
|
|
445
|
+
if (settled === 'aborted')
|
|
446
|
+
return 'aborted';
|
|
447
|
+
if (settled === 'closed')
|
|
448
|
+
return 'aborted';
|
|
449
|
+
if (rejected)
|
|
450
|
+
return 'error';
|
|
451
|
+
return answer?.decision === 'deny' ? 'deny' : 'allow';
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Bound the one caller-supplied string that becomes MODEL-VISIBLE.
|
|
455
|
+
*
|
|
456
|
+
* `denialMessage` puts the reason into a `tool_result`, so `maxDenyReasonChars`
|
|
457
|
+
* has to be applied HERE - where the answer enters the CLI - rather than only on
|
|
458
|
+
* the echoed frame and the audit line. Otherwise a megabyte of "reason" would
|
|
459
|
+
* ride into every later provider request, which is exactly the unbounded
|
|
460
|
+
* prompt-injection lane the limit exists to close.
|
|
461
|
+
*/
|
|
462
|
+
function clipAnswerReason(answer) {
|
|
463
|
+
const reason = answer.reason;
|
|
464
|
+
if (typeof reason !== 'string')
|
|
465
|
+
return answer;
|
|
466
|
+
const clipped = clipReason(reason);
|
|
467
|
+
return clipped === reason ? answer : { ...answer, reason: clipped };
|
|
468
|
+
}
|
|
469
|
+
function clipReason(reason) {
|
|
470
|
+
return reason.length <= BRIDGE_LIMITS.maxDenyReasonChars
|
|
471
|
+
? reason
|
|
472
|
+
: reason.slice(0, BRIDGE_LIMITS.maxDenyReasonChars);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Bound the echoed `input`. The caller needs it to act, so it travels - but a
|
|
476
|
+
* megabyte of SQL does not need to travel four times (invoke, event, result, log).
|
|
477
|
+
*/
|
|
478
|
+
function truncateInput(input) {
|
|
479
|
+
const serialized = JSON.stringify(input ?? {});
|
|
480
|
+
if (serialized.length <= BRIDGE_LIMITS.maxInvokeInputChars)
|
|
481
|
+
return input ?? {};
|
|
482
|
+
return { truncated: true, preview: serialized.slice(0, BRIDGE_LIMITS.maxInvokeInputChars) };
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* The `tool_result` payload as an `AgentTool` result.
|
|
486
|
+
*
|
|
487
|
+
* TWO FAILURE SHAPES, BOTH NAMED: content this build cannot accept (an `image`
|
|
488
|
+
* part, refused rather than silently dropped) and the caller's own `isError`. A
|
|
489
|
+
* third possibility - an exception escaping - would be converted by
|
|
490
|
+
* `ToolExecutor` into wording that says nothing about which of the two happened.
|
|
491
|
+
*/
|
|
492
|
+
function buildCallerToolResult(call, value, protocolError) {
|
|
493
|
+
try {
|
|
494
|
+
return callerToolResult(value.content, value.isError === true);
|
|
495
|
+
}
|
|
496
|
+
catch (e) {
|
|
497
|
+
if (!(e instanceof UnsupportedContentError))
|
|
498
|
+
throw e;
|
|
499
|
+
protocolError('bridge_unsupported_content', `Caller tool "${call.tool}": ${e.message}`);
|
|
500
|
+
return errorResult(`Caller tool "${call.tool}" returned content this build cannot accept: ${e.message}`);
|
|
501
|
+
}
|
|
502
|
+
}
|