@aragon-agent/cli 0.6.38 → 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/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/shortcuts.d.ts +81 -0
- package/dist/ui/shortcuts.js +221 -0
- package/package.json +2 -2
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caller-served tools: what a `tool_register` frame becomes, and how it runs.
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* ## I-12: THESE TOOLS LIVE OUTSIDE THE HOST'S JURISDICTION, ON PURPOSE
|
|
7
|
+
*
|
|
8
|
+
* Everything below is built HERE and handed straight to the registry. That means a
|
|
9
|
+
* caller tool is NOT covered by the plan gate, NOT covered by a skill's
|
|
10
|
+
* `allowed-tools` ceiling, and has no job-notice tail - because it never passes
|
|
11
|
+
* through `createBuiltinTools`, which is where all three wrappers are applied.
|
|
12
|
+
*
|
|
13
|
+
* This is a decision, not an oversight, and it is written down because the next
|
|
14
|
+
* person to read this file will otherwise assume the wrappers are there. The
|
|
15
|
+
* defences that DO apply are: plan mode refuses the whole `tool_register` frame
|
|
16
|
+
* (`plan_mode_read_only`), and a caller tool may not shadow any host, team or
|
|
17
|
+
* skill tool name (`caller_tool_conflict`). Both are refusals at registration
|
|
18
|
+
* time, which is the only moment this module can still see the caller's intent.
|
|
19
|
+
*
|
|
20
|
+
* ## Why `validateCallerTools` REFUSES WHOLE FRAMES
|
|
21
|
+
*
|
|
22
|
+
* A partially accepted registration would leave the registry holding a mixture of
|
|
23
|
+
* two declarations - the caller's old `db_query` and its new `db_write` - with
|
|
24
|
+
* nothing anywhere saying which of the two the model is now using. The caller's
|
|
25
|
+
* natural recovery ("just send the fixed list again") only works if the previous
|
|
26
|
+
* frame left no trace, so every check below runs BEFORE the first write.
|
|
27
|
+
*/
|
|
28
|
+
import { errorResult, textResult, } from '@aragon-agent/core';
|
|
29
|
+
import { truncateToBytes } from '@aragon-agent/core/skills';
|
|
30
|
+
import { BRIDGE_LIMITS } from './limits.js';
|
|
31
|
+
const CALLER_TOOL_NAME = /^[a-z][a-z0-9_]{0,39}$/;
|
|
32
|
+
function reject(code, message) {
|
|
33
|
+
return { ok: false, code, message };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Narrow whatever arrived in `tool_register.tools` into usable specs.
|
|
37
|
+
*
|
|
38
|
+
* `reserved` is a PARAMETER rather than an import so the caller decides what a
|
|
39
|
+
* name may not collide with - and so the test can hand over a two-name set instead
|
|
40
|
+
* of modelling the whole host.
|
|
41
|
+
*/
|
|
42
|
+
export function validateCallerTools(rawTools, reserved) {
|
|
43
|
+
if (rawTools.length === 0) {
|
|
44
|
+
// THE ONE ACCEPTED DEGENERATE FRAME: an empty array is "I serve nothing now",
|
|
45
|
+
// which is a real action and the only way to withdraw a tool mid-run.
|
|
46
|
+
return { ok: true, specs: [], warnings: [] };
|
|
47
|
+
}
|
|
48
|
+
if (rawTools.length > BRIDGE_LIMITS.maxCallerTools) {
|
|
49
|
+
return reject('caller_tools_too_many', `${rawTools.length} caller tools declared; the limit is ${BRIDGE_LIMITS.maxCallerTools}.`);
|
|
50
|
+
}
|
|
51
|
+
const specs = [];
|
|
52
|
+
const warnings = [];
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
for (const raw of rawTools) {
|
|
55
|
+
const one = validateOne(raw, reserved, seen);
|
|
56
|
+
if (!one.ok)
|
|
57
|
+
return one;
|
|
58
|
+
specs.push(one.spec);
|
|
59
|
+
if (one.warning)
|
|
60
|
+
warnings.push(one.warning);
|
|
61
|
+
}
|
|
62
|
+
return { ok: true, specs, warnings };
|
|
63
|
+
}
|
|
64
|
+
function validateOne(raw, reserved, seen) {
|
|
65
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
66
|
+
return reject('caller_tool_bad_schema', 'Every entry in "tools" must be an object.');
|
|
67
|
+
}
|
|
68
|
+
const record = raw;
|
|
69
|
+
const name = typeof record.name === 'string' ? record.name : '';
|
|
70
|
+
if (!CALLER_TOOL_NAME.test(name)) {
|
|
71
|
+
return reject('caller_tool_bad_name', `Caller tool name "${name}" must match ${String(CALLER_TOOL_NAME)} ` +
|
|
72
|
+
`(at most ${BRIDGE_LIMITS.maxCallerToolNameChars} characters).`);
|
|
73
|
+
}
|
|
74
|
+
if (reserved.has(name) || seen.has(name)) {
|
|
75
|
+
return reject('caller_tool_conflict', `Caller tool "${name}" collides with a tool this host already registers, ` +
|
|
76
|
+
'or with an earlier entry in this same frame.');
|
|
77
|
+
}
|
|
78
|
+
seen.add(name);
|
|
79
|
+
const parameters = record.parameters;
|
|
80
|
+
if (!parameters || typeof parameters !== 'object' || Array.isArray(parameters)) {
|
|
81
|
+
return reject('caller_tool_bad_schema', `Caller tool "${name}" needs a "parameters" object.`);
|
|
82
|
+
}
|
|
83
|
+
const schema = parameters;
|
|
84
|
+
if (schema.type !== 'object') {
|
|
85
|
+
return reject('caller_tool_bad_schema', `Caller tool "${name}" needs "parameters.type" to be "object".`);
|
|
86
|
+
}
|
|
87
|
+
if (Buffer.byteLength(JSON.stringify(schema), 'utf-8') > BRIDGE_LIMITS.maxCallerToolSchemaBytes) {
|
|
88
|
+
return reject('caller_tool_bad_schema', `Caller tool "${name}" has a schema larger than ${BRIDGE_LIMITS.maxCallerToolSchemaBytes} bytes.`);
|
|
89
|
+
}
|
|
90
|
+
const descriptionRaw = typeof record.description === 'string' ? record.description : '';
|
|
91
|
+
const clipped = descriptionRaw.length > BRIDGE_LIMITS.maxCallerToolDescriptionChars;
|
|
92
|
+
const description = clipped
|
|
93
|
+
? descriptionRaw.slice(0, BRIDGE_LIMITS.maxCallerToolDescriptionChars)
|
|
94
|
+
: descriptionRaw;
|
|
95
|
+
const label = typeof record.label === 'string' && record.label.length > 0
|
|
96
|
+
? record.label
|
|
97
|
+
: name;
|
|
98
|
+
const timeoutMs = typeof record.timeoutMs === 'number' && Number.isFinite(record.timeoutMs)
|
|
99
|
+
? record.timeoutMs
|
|
100
|
+
: undefined;
|
|
101
|
+
const spec = {
|
|
102
|
+
name,
|
|
103
|
+
label,
|
|
104
|
+
description,
|
|
105
|
+
parameters: schema,
|
|
106
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
107
|
+
};
|
|
108
|
+
// DESCRIPTION LENGTH IS A WARNING, NOT A REFUSAL: nothing about safety depends
|
|
109
|
+
// on it, and failing the whole frame over prose would cost the caller a round
|
|
110
|
+
// trip and teach it nothing.
|
|
111
|
+
return clipped
|
|
112
|
+
? { ok: true, spec, warning: `Caller tool "${name}": description truncated to ${description.length} characters.` }
|
|
113
|
+
: { ok: true, spec };
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The effective ceiling for one call: `min(spec.timeoutMs, --caller-tool-timeout)`.
|
|
117
|
+
*
|
|
118
|
+
* MIN, NOT MAX, because both numbers are ceilings on the same thing: the caller's
|
|
119
|
+
* own declaration is a promise about its implementation ("this query takes at most
|
|
120
|
+
* 20 s") and the flag is the run's budget. Taking the larger of the two would let
|
|
121
|
+
* a declared 600 s override a 10 s budget.
|
|
122
|
+
*/
|
|
123
|
+
export function resolveCallerToolTimeout(spec, defaultTimeoutMs) {
|
|
124
|
+
const declared = spec?.timeoutMs;
|
|
125
|
+
if (declared === undefined || !Number.isFinite(declared) || declared <= 0) {
|
|
126
|
+
return Math.min(defaultTimeoutMs, BRIDGE_LIMITS.maxCallerToolTimeoutMs);
|
|
127
|
+
}
|
|
128
|
+
return Math.min(declared, defaultTimeoutMs, BRIDGE_LIMITS.maxCallerToolTimeoutMs);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Turn specs into `AgentTool`s that round-trip through the server.
|
|
132
|
+
*
|
|
133
|
+
* EVERY PATH RETURNS A `ToolResult`. Nothing here throws: `ToolExecutor` would
|
|
134
|
+
* convert a throw into an error result of its own wording, and that wording says
|
|
135
|
+
* nothing about which of the four refusals happened.
|
|
136
|
+
*/
|
|
137
|
+
export function createCallerTools(specs, server, defaultTimeoutMs) {
|
|
138
|
+
return specs.map((spec) => makeCallerTool(spec, server, defaultTimeoutMs));
|
|
139
|
+
}
|
|
140
|
+
function makeCallerTool(spec, server, defaultTimeoutMs) {
|
|
141
|
+
return {
|
|
142
|
+
name: spec.name,
|
|
143
|
+
label: spec.label ?? spec.name,
|
|
144
|
+
description: spec.description,
|
|
145
|
+
parameters: spec.parameters,
|
|
146
|
+
async execute(toolCallId, params, ctx) {
|
|
147
|
+
const call = {
|
|
148
|
+
callId: toolCallId,
|
|
149
|
+
turn: server.currentTurn(),
|
|
150
|
+
tool: spec.name,
|
|
151
|
+
input: (params ?? {}),
|
|
152
|
+
timeoutMs: resolveCallerToolTimeout(spec, defaultTimeoutMs),
|
|
153
|
+
agent: { id: 'lead', label: 'lead', depth: 0 },
|
|
154
|
+
};
|
|
155
|
+
return server.invokeCallerTool(call, ctx.signal);
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Coerce a `tool_result.content` into text, or refuse it.
|
|
161
|
+
*
|
|
162
|
+
* TEXT ONLY, THIS ROUND. An image part would need `imageResult` and the provider's
|
|
163
|
+
* multimodal path, which is a separate feature with its own budget questions; a
|
|
164
|
+
* half-support that silently dropped the image would be worse than a named
|
|
165
|
+
* refusal (`bridge_unsupported_content`).
|
|
166
|
+
*/
|
|
167
|
+
export function contentToText(content) {
|
|
168
|
+
if (typeof content === 'string')
|
|
169
|
+
return { ok: true, text: content };
|
|
170
|
+
if (!Array.isArray(content))
|
|
171
|
+
return { ok: false };
|
|
172
|
+
const parts = [];
|
|
173
|
+
for (const part of content) {
|
|
174
|
+
if (!part || typeof part !== 'object')
|
|
175
|
+
return { ok: false };
|
|
176
|
+
const block = part;
|
|
177
|
+
if (block.type !== 'text' || typeof block.text !== 'string')
|
|
178
|
+
return { ok: false };
|
|
179
|
+
parts.push(block.text);
|
|
180
|
+
}
|
|
181
|
+
return { ok: true, text: parts.join('\n') };
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Clip to `maxCallerToolResultBytes` and SAY SO.
|
|
185
|
+
*
|
|
186
|
+
* The footer is not decoration: `ToolExecutor` already clips model-visible output
|
|
187
|
+
* at its own ceiling, so a silent second clip here would look like the tool
|
|
188
|
+
* returned less than it did, and the model would draw conclusions from a truncated
|
|
189
|
+
* table without being told it was truncated.
|
|
190
|
+
*/
|
|
191
|
+
export function clipCallerToolText(text) {
|
|
192
|
+
const budget = BRIDGE_LIMITS.maxCallerToolResultBytes;
|
|
193
|
+
if (Buffer.byteLength(text, 'utf-8') <= budget)
|
|
194
|
+
return text;
|
|
195
|
+
const head = truncateToBytes(text, budget);
|
|
196
|
+
return `${head}\n[truncated at ${budget} bytes]`;
|
|
197
|
+
}
|
|
198
|
+
/** Build the result a `tool_result` frame becomes, or a named refusal. */
|
|
199
|
+
export function callerToolResult(content, isError) {
|
|
200
|
+
const text = contentToText(content);
|
|
201
|
+
if (!text.ok) {
|
|
202
|
+
throw new UnsupportedContentError();
|
|
203
|
+
}
|
|
204
|
+
const clipped = clipCallerToolText(text.text);
|
|
205
|
+
return isError ? errorResult(clipped) : textResult(clipped);
|
|
206
|
+
}
|
|
207
|
+
/** Thrown only so `server.ts` can map it to one error code in one place. */
|
|
208
|
+
export class UnsupportedContentError extends Error {
|
|
209
|
+
constructor() {
|
|
210
|
+
super('Caller tools support text content only (bridge_unsupported_content).');
|
|
211
|
+
this.name = 'UnsupportedContentError';
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three host gates, wired to the caller instead of to an overlay.
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* THREE GATES, THREE DIFFERENT CONTRACTS, ONE SERVER. They are not variations of
|
|
7
|
+
* one another and cannot be collapsed:
|
|
8
|
+
*
|
|
9
|
+
* `ToolGate` refuses with a MODEL-VISIBLE sentence (a tool result).
|
|
10
|
+
* `HumanInputGate` resolves `null` on cancel and NEVER rejects.
|
|
11
|
+
* `ApprovalGate` answers a boolean, and only ever after `canPrompt()`.
|
|
12
|
+
*
|
|
13
|
+
* The mapping from a `DecisionOutcome` to each of those is the whole content of
|
|
14
|
+
* this file, and each mapping is justified where it happens. What they share is
|
|
15
|
+
* the pending table, the ids, the deadlines and the capacity ceiling - which is
|
|
16
|
+
* exactly why one protocol was worth building instead of three.
|
|
17
|
+
*
|
|
18
|
+
* `canPrompt()` IS A LIVE PROBE EVERYWHERE. All three read `server.isOpen()` on
|
|
19
|
+
* every call and none of them caches it: the value changes the moment the caller
|
|
20
|
+
* goes away, and a cached `true` would keep a headless run waiting for an answer
|
|
21
|
+
* from a process that has already exited.
|
|
22
|
+
*/
|
|
23
|
+
import type { ApprovalGate } from '../../skills/service.js';
|
|
24
|
+
import type { HumanInputGate } from '../../tools/human-input.js';
|
|
25
|
+
import type { ToolGate } from '../../tools/tool-gate.js';
|
|
26
|
+
import type { PermissionMode } from '../permission.js';
|
|
27
|
+
import { type AgentScope } from './protocol.js';
|
|
28
|
+
import type { BridgeServer } from './server.js';
|
|
29
|
+
export interface CallerToolGateOptions {
|
|
30
|
+
server: BridgeServer;
|
|
31
|
+
/** Only `ask` gates anything; every other mode registers and runs as today. */
|
|
32
|
+
mode: PermissionMode;
|
|
33
|
+
/** `--allow-tool`: PRE-AUTHORISED names, which never produce a request. */
|
|
34
|
+
allow: ReadonlySet<string>;
|
|
35
|
+
/** Who is calling: `{id,label,depth}`, so the caller can render the right chip. */
|
|
36
|
+
scope: AgentScope;
|
|
37
|
+
/**
|
|
38
|
+
* `--decision-timeout`, carried so a timeout can name how long it waited.
|
|
39
|
+
*
|
|
40
|
+
* NOT DERIVED FROM `deadlineAt`: the outcome reports when the deadline WAS, and
|
|
41
|
+
* `Date.now()` arithmetic against it would print a number that depends on how
|
|
42
|
+
* late the timer fired rather than on the budget the caller was given.
|
|
43
|
+
*/
|
|
44
|
+
decisionTimeoutMs: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The tool-approval gate.
|
|
48
|
+
*
|
|
49
|
+
* THREE SHORT CIRCUITS, IN THIS ORDER, AND EACH ONE IS A TEST (AC-14 / AC-15):
|
|
50
|
+
*
|
|
51
|
+
* 1. the tool is not in `CALLER_GATED_TOOLS` -> allow, never ask
|
|
52
|
+
* 2. the mode is not `ask` -> allow, never ask
|
|
53
|
+
* 3. the name was pre-authorised or allowed this run -> allow, never ask
|
|
54
|
+
*
|
|
55
|
+
* THE SESSION SET LIVES HERE AND NOT IN `applies()`. `applies()` answers "does
|
|
56
|
+
* this tool have a gate at all" and must stay a pure function of the name, or the
|
|
57
|
+
* identity assertions that prove "no gate means no change" become undecidable.
|
|
58
|
+
*/
|
|
59
|
+
export declare function createCallerToolGate(options: CallerToolGateOptions): ToolGate;
|
|
60
|
+
/**
|
|
61
|
+
* `skill_install` approval.
|
|
62
|
+
*
|
|
63
|
+
* `canPrompt()` IS THE WHOLE SAFETY MECHANISM: the installer probes it before
|
|
64
|
+
* asking, and the deny-all gate exists precisely so a headless run refuses
|
|
65
|
+
* unattended installs. Here "there is a human" means "the caller's pipe is open",
|
|
66
|
+
* which is true for exactly as long as the caller is listening.
|
|
67
|
+
*/
|
|
68
|
+
export declare function createCallerApprovalGate(server: BridgeServer): ApprovalGate;
|
|
69
|
+
/**
|
|
70
|
+
* `ask_user` / `submit_plan` over the wire.
|
|
71
|
+
*
|
|
72
|
+
* `neverPrompts` IS DELIBERATELY ABSENT. It is the STATIC "nobody can ever answer"
|
|
73
|
+
* flag that `createPlanTools` keys registration off; setting it here would
|
|
74
|
+
* unregister both tools and a caller could never review a plan, which is the
|
|
75
|
+
* feature this mode exists for. Omitting it is the correct expression, not an
|
|
76
|
+
* oversight - the type makes it optional exactly for this gate.
|
|
77
|
+
*/
|
|
78
|
+
export declare function createCallerHumanInputGate(server: BridgeServer): HumanInputGate;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { denialMessage, interruptedMessage, timeoutMessage } from '../../tools/tool-gate.js';
|
|
2
|
+
import { CALLER_GATED_TOOLS } from '../../tools/index.js';
|
|
3
|
+
import { acceptsHumanAnswer, validateHumanResponse } from './human-answers.js';
|
|
4
|
+
import { humanDecisionFrame, } from './protocol.js';
|
|
5
|
+
/**
|
|
6
|
+
* The tool-approval gate.
|
|
7
|
+
*
|
|
8
|
+
* THREE SHORT CIRCUITS, IN THIS ORDER, AND EACH ONE IS A TEST (AC-14 / AC-15):
|
|
9
|
+
*
|
|
10
|
+
* 1. the tool is not in `CALLER_GATED_TOOLS` -> allow, never ask
|
|
11
|
+
* 2. the mode is not `ask` -> allow, never ask
|
|
12
|
+
* 3. the name was pre-authorised or allowed this run -> allow, never ask
|
|
13
|
+
*
|
|
14
|
+
* THE SESSION SET LIVES HERE AND NOT IN `applies()`. `applies()` answers "does
|
|
15
|
+
* this tool have a gate at all" and must stay a pure function of the name, or the
|
|
16
|
+
* identity assertions that prove "no gate means no change" become undecidable.
|
|
17
|
+
*/
|
|
18
|
+
export function createCallerToolGate(options) {
|
|
19
|
+
const { server, mode, allow, scope } = options;
|
|
20
|
+
/** `scope:'session'` answers, in memory only; never written to config (I-7). */
|
|
21
|
+
const sessionAllow = new Set();
|
|
22
|
+
return {
|
|
23
|
+
applies: (toolName) => CALLER_GATED_TOOLS.has(toolName),
|
|
24
|
+
async decide(request) {
|
|
25
|
+
if (mode !== 'ask')
|
|
26
|
+
return { allow: true };
|
|
27
|
+
if (allow.has(request.tool) || sessionAllow.has(request.tool))
|
|
28
|
+
return { allow: true };
|
|
29
|
+
const outcome = await server.requestDecision({
|
|
30
|
+
kind: 'tool',
|
|
31
|
+
tool: {
|
|
32
|
+
name: request.tool,
|
|
33
|
+
callId: request.callId,
|
|
34
|
+
input: request.input,
|
|
35
|
+
summary: request.summary,
|
|
36
|
+
turn: request.turn ?? server.currentTurn(),
|
|
37
|
+
},
|
|
38
|
+
agent: scope,
|
|
39
|
+
}, request.signal, (answer) => answer.decision === 'allow' || answer.decision === 'deny');
|
|
40
|
+
return verdictOf(request.tool, outcome, sessionAllow, options.decisionTimeoutMs);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Map an outcome onto the sentence the model reads.
|
|
46
|
+
*
|
|
47
|
+
* FOUR DISTINCT TEXTS, NEVER ONE (see `tool-gate.ts`): "denied", "nobody answered
|
|
48
|
+
* within N ms", and "the turn was interrupted" lead a model to three different
|
|
49
|
+
* next actions, and the middle one is the only case where waiting longer would
|
|
50
|
+
* have helped.
|
|
51
|
+
*/
|
|
52
|
+
function verdictOf(tool, outcome, sessionAllow, decisionTimeoutMs) {
|
|
53
|
+
if (outcome.outcome === 'answered') {
|
|
54
|
+
if (outcome.answer.decision === 'allow') {
|
|
55
|
+
if (outcome.answer.scope === 'session')
|
|
56
|
+
sessionAllow.add(tool);
|
|
57
|
+
return { allow: true };
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
allow: false,
|
|
61
|
+
...(outcome.answer.reason ? { reason: outcome.answer.reason } : {}),
|
|
62
|
+
message: denialMessage(tool, outcome.answer.reason),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (outcome.outcome === 'timeout') {
|
|
66
|
+
return {
|
|
67
|
+
allow: false,
|
|
68
|
+
reason: 'decision_timeout',
|
|
69
|
+
message: timeoutMessage(tool, decisionTimeoutMs),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (outcome.outcome === 'aborted') {
|
|
73
|
+
return { allow: false, reason: 'turn_aborted', message: interruptedMessage(tool) };
|
|
74
|
+
}
|
|
75
|
+
return { allow: false, reason: outcome.reason, message: interruptedMessage(tool) };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* `skill_install` approval.
|
|
79
|
+
*
|
|
80
|
+
* `canPrompt()` IS THE WHOLE SAFETY MECHANISM: the installer probes it before
|
|
81
|
+
* asking, and the deny-all gate exists precisely so a headless run refuses
|
|
82
|
+
* unattended installs. Here "there is a human" means "the caller's pipe is open",
|
|
83
|
+
* which is true for exactly as long as the caller is listening.
|
|
84
|
+
*/
|
|
85
|
+
export function createCallerApprovalGate(server) {
|
|
86
|
+
return {
|
|
87
|
+
canPrompt: () => server.isOpen(),
|
|
88
|
+
request: async (req) => {
|
|
89
|
+
const outcome = await server.requestDecision({ kind: 'skill_install', install: { tool: req.tool, summary: req.summary } }, req.signal, (answer) => answer.decision === 'allow' || answer.decision === 'deny');
|
|
90
|
+
return outcome.outcome === 'answered' && outcome.answer.decision === 'allow';
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* `ask_user` / `submit_plan` over the wire.
|
|
96
|
+
*
|
|
97
|
+
* `neverPrompts` IS DELIBERATELY ABSENT. It is the STATIC "nobody can ever answer"
|
|
98
|
+
* flag that `createPlanTools` keys registration off; setting it here would
|
|
99
|
+
* unregister both tools and a caller could never review a plan, which is the
|
|
100
|
+
* feature this mode exists for. Omitting it is the correct expression, not an
|
|
101
|
+
* oversight - the type makes it optional exactly for this gate.
|
|
102
|
+
*/
|
|
103
|
+
export function createCallerHumanInputGate(server) {
|
|
104
|
+
return {
|
|
105
|
+
canPrompt: () => server.isOpen(),
|
|
106
|
+
request: async (req, signal) => {
|
|
107
|
+
const outcome = await server.requestDecision(humanDecisionFrame(req), signal, (answer) => acceptsHumanAnswer(req.kind, answer));
|
|
108
|
+
// THE GATE CONTRACT IS "RESOLVES null, NEVER REJECTS". Everything that is
|
|
109
|
+
// not an answer is `null` - not an empty answer, which `plan-tools` would
|
|
110
|
+
// read as "the user answered nothing and agreed".
|
|
111
|
+
if (outcome.outcome !== 'answered')
|
|
112
|
+
return null;
|
|
113
|
+
return validateHumanResponse(req, outcome.answer);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trust boundary for answers that came off a pipe.
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* EVERY FIELD BELOW IS MODEL-VISIBLE AFTER THIS FUNCTION. An `ask_user` answer
|
|
7
|
+
* becomes a `tool_result`, a `submit_plan` verdict becomes the sentence the model
|
|
8
|
+
* reasons over, and both land in the next provider request. So this is the one
|
|
9
|
+
* place where "the caller said so" has to become "the CLI asserts so", and the
|
|
10
|
+
* rules are deliberately lossy: an answer that cannot be mapped onto a question
|
|
11
|
+
* the MODEL asked is dropped rather than repaired, because a fabricated answer is
|
|
12
|
+
* worse than a missing one - it reads as a fact.
|
|
13
|
+
*
|
|
14
|
+
* V11 IS THE SUBTLE ONE. `Answer.question` is free text that this file writes; it
|
|
15
|
+
* is never accepted from the frame. Otherwise an embedder could put arbitrary
|
|
16
|
+
* words into the model's context through a field the CLI never inspects, which
|
|
17
|
+
* would make the approval channel a prompt-injection channel.
|
|
18
|
+
*/
|
|
19
|
+
import { type HumanRequest, type HumanResponse } from '../../tools/human-input.js';
|
|
20
|
+
import type { DecisionAnswer } from './protocol.js';
|
|
21
|
+
/**
|
|
22
|
+
* V1/V10: is this frame usable at all?
|
|
23
|
+
*
|
|
24
|
+
* Used as the `validate` callback on `requestDecision`, so a mangled frame is
|
|
25
|
+
* reported as `decision_resolved{outcome:'error'}` instead of being settled as a
|
|
26
|
+
* legitimate refusal. It answers only "is there an answer here", never "is the
|
|
27
|
+
* answer good" - those are different failures and the codes differ
|
|
28
|
+
* (`bridge_frame_invalid` vs a dropped option).
|
|
29
|
+
*/
|
|
30
|
+
export declare function acceptsHumanAnswer(kind: 'questions' | 'plan', answer: DecisionAnswer): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Turn an untrusted answer into the shape `tools/human-input.ts` defines.
|
|
33
|
+
*
|
|
34
|
+
* NEVER THROWS AND NEVER REJECTS: the gate this feeds has a documented "resolves
|
|
35
|
+
* null on cancel" contract, and an exception would break it at the one moment a
|
|
36
|
+
* headless run cannot afford a stack trace.
|
|
37
|
+
*/
|
|
38
|
+
export declare function validateHumanResponse(req: HumanRequest, answer: DecisionAnswer): HumanResponse | null;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trust boundary for answers that came off a pipe.
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* EVERY FIELD BELOW IS MODEL-VISIBLE AFTER THIS FUNCTION. An `ask_user` answer
|
|
7
|
+
* becomes a `tool_result`, a `submit_plan` verdict becomes the sentence the model
|
|
8
|
+
* reasons over, and both land in the next provider request. So this is the one
|
|
9
|
+
* place where "the caller said so" has to become "the CLI asserts so", and the
|
|
10
|
+
* rules are deliberately lossy: an answer that cannot be mapped onto a question
|
|
11
|
+
* the MODEL asked is dropped rather than repaired, because a fabricated answer is
|
|
12
|
+
* worse than a missing one - it reads as a fact.
|
|
13
|
+
*
|
|
14
|
+
* V11 IS THE SUBTLE ONE. `Answer.question` is free text that this file writes; it
|
|
15
|
+
* is never accepted from the frame. Otherwise an embedder could put arbitrary
|
|
16
|
+
* words into the model's context through a field the CLI never inspects, which
|
|
17
|
+
* would make the approval channel a prompt-injection channel.
|
|
18
|
+
*/
|
|
19
|
+
import { QUESTION_LIMITS } from '../../tools/human-input.js';
|
|
20
|
+
import { BRIDGE_LIMITS } from './limits.js';
|
|
21
|
+
/** The three plan verdicts. `revise` is only meaningful with feedback. */
|
|
22
|
+
const PLAN_DECISIONS = ['approved', 'revise', 'dismissed'];
|
|
23
|
+
/**
|
|
24
|
+
* V1/V10: is this frame usable at all?
|
|
25
|
+
*
|
|
26
|
+
* Used as the `validate` callback on `requestDecision`, so a mangled frame is
|
|
27
|
+
* reported as `decision_resolved{outcome:'error'}` instead of being settled as a
|
|
28
|
+
* legitimate refusal. It answers only "is there an answer here", never "is the
|
|
29
|
+
* answer good" - those are different failures and the codes differ
|
|
30
|
+
* (`bridge_frame_invalid` vs a dropped option).
|
|
31
|
+
*/
|
|
32
|
+
export function acceptsHumanAnswer(kind, answer) {
|
|
33
|
+
if (kind === 'questions') {
|
|
34
|
+
if (answer.cancelled === true)
|
|
35
|
+
return true;
|
|
36
|
+
return Array.isArray(answer.answers);
|
|
37
|
+
}
|
|
38
|
+
return typeof answer.planDecision === 'string';
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Turn an untrusted answer into the shape `tools/human-input.ts` defines.
|
|
42
|
+
*
|
|
43
|
+
* NEVER THROWS AND NEVER REJECTS: the gate this feeds has a documented "resolves
|
|
44
|
+
* null on cancel" contract, and an exception would break it at the one moment a
|
|
45
|
+
* headless run cannot afford a stack trace.
|
|
46
|
+
*/
|
|
47
|
+
export function validateHumanResponse(req, answer) {
|
|
48
|
+
return req.kind === 'questions'
|
|
49
|
+
? validateAnswers(req.questions, answer)
|
|
50
|
+
: validatePlanDecision(answer);
|
|
51
|
+
}
|
|
52
|
+
function validateAnswers(questions, answer) {
|
|
53
|
+
const raw = Array.isArray(answer.answers) ? answer.answers : [];
|
|
54
|
+
const answers = [];
|
|
55
|
+
const byId = new Map(questions.map((question) => [question.id, question]));
|
|
56
|
+
/**
|
|
57
|
+
* FIRST ANSWER WINS, and a repeat is dropped like any other unmappable entry.
|
|
58
|
+
*
|
|
59
|
+
* WITHOUT THIS the caller controls the COUNT: forty copies of one question's
|
|
60
|
+
* answer render as "User answered 40 of 2 questions", because
|
|
61
|
+
* `formatAnswersResult` counts entries rather than distinct ids.
|
|
62
|
+
*/
|
|
63
|
+
const used = new Set();
|
|
64
|
+
for (const entry of raw) {
|
|
65
|
+
if (answers.length >= QUESTION_LIMITS.maxQuestions)
|
|
66
|
+
break;
|
|
67
|
+
const one = validateAnswer(entry, byId);
|
|
68
|
+
if (!one || used.has(one.id))
|
|
69
|
+
continue;
|
|
70
|
+
used.add(one.id);
|
|
71
|
+
answers.push(one);
|
|
72
|
+
}
|
|
73
|
+
// A completely empty, non-cancelled answer is a FAIL-CLOSED short circuit: the
|
|
74
|
+
// wizard contract distinguishes "the user cancelled" from "the user answered
|
|
75
|
+
// nothing", and reporting the second as the first would tell the model to
|
|
76
|
+
// proceed on assumptions nobody made.
|
|
77
|
+
if (answers.length === 0 && answer.cancelled !== true) {
|
|
78
|
+
return { kind: 'answers', answers: [], cancelled: true };
|
|
79
|
+
}
|
|
80
|
+
// PARTIAL ANSWERS ARE PRESERVED WITH `cancelled` (V6): `formatAnswersResult`
|
|
81
|
+
// already renders "User answered 1 of 3" and plan-tools already tolerates it.
|
|
82
|
+
return { kind: 'answers', answers, cancelled: answer.cancelled === true };
|
|
83
|
+
}
|
|
84
|
+
function validateAnswer(entry, byId) {
|
|
85
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
86
|
+
return null;
|
|
87
|
+
const record = entry;
|
|
88
|
+
// V2: an id the model never asked about is dropped, not invented.
|
|
89
|
+
const question = typeof record.id === 'string' ? byId.get(record.id) : undefined;
|
|
90
|
+
if (!question)
|
|
91
|
+
return null;
|
|
92
|
+
const selected = selectLabels(question, record.selected);
|
|
93
|
+
if (selected.length === 0)
|
|
94
|
+
return null;
|
|
95
|
+
const custom = customText(question, selected, record.custom);
|
|
96
|
+
return {
|
|
97
|
+
id: question.id,
|
|
98
|
+
// V11: THE CLI'S TEXT, not the caller's. See the module header.
|
|
99
|
+
question: question.question,
|
|
100
|
+
selected,
|
|
101
|
+
custom,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** V3 + V4: exact labels only, and one of them unless `allowMultiple`. */
|
|
105
|
+
function selectLabels(question, raw) {
|
|
106
|
+
const wanted = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [];
|
|
107
|
+
const labels = question.options.map((option) => option.label);
|
|
108
|
+
const selected = [];
|
|
109
|
+
for (const candidate of wanted) {
|
|
110
|
+
if (typeof candidate !== 'string')
|
|
111
|
+
continue;
|
|
112
|
+
if (!labels.includes(candidate))
|
|
113
|
+
continue;
|
|
114
|
+
if (selected.includes(candidate))
|
|
115
|
+
continue;
|
|
116
|
+
selected.push(candidate);
|
|
117
|
+
if (!question.allowMultiple)
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
return selected;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* V5: `custom` is only meaningful when the synthetic `Other` option was picked.
|
|
124
|
+
*
|
|
125
|
+
* THE TEXT IS DROPPED, THE CHOICE IS KEPT. A free-text field attached to a fixed
|
|
126
|
+
* choice is a contradiction the wizard cannot produce, so it is a caller bug - and
|
|
127
|
+
* the safe reading of a caller bug is to keep what was clearly asked for and
|
|
128
|
+
* discard the part that was not.
|
|
129
|
+
*/
|
|
130
|
+
function customText(question, selected, raw) {
|
|
131
|
+
const other = question.options.find((option) => option.isOther);
|
|
132
|
+
const otherChosen = other ? selected.includes(other.label) : false;
|
|
133
|
+
const text = typeof raw === 'string' ? raw.trim() : '';
|
|
134
|
+
if (text.length === 0)
|
|
135
|
+
return null;
|
|
136
|
+
// A free-text field attached to a fixed choice is a contradiction the wizard
|
|
137
|
+
// could not have produced, so the TEXT is dropped and the choice is kept.
|
|
138
|
+
if (!otherChosen)
|
|
139
|
+
return null;
|
|
140
|
+
return text.length <= BRIDGE_LIMITS.maxCustomAnswerChars
|
|
141
|
+
? text
|
|
142
|
+
: text.slice(0, BRIDGE_LIMITS.maxCustomAnswerChars);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* V8 + V9: the plan verdict.
|
|
146
|
+
*
|
|
147
|
+
* `revise` WITH NO FEEDBACK IS DOWNGRADED TO `dismissed`, deliberately. The
|
|
148
|
+
* alternative - passing it through - produces a model that re-submits the same
|
|
149
|
+
* plan unchanged, so the caller pays a full research turn to see its own plan
|
|
150
|
+
* again. "Dismissed" at least ends the loop with a defensible reading.
|
|
151
|
+
*/
|
|
152
|
+
function validatePlanDecision(answer) {
|
|
153
|
+
const raw = answer.planDecision;
|
|
154
|
+
if (typeof raw !== 'string' || !PLAN_DECISIONS.includes(raw))
|
|
155
|
+
return null;
|
|
156
|
+
const decision = raw;
|
|
157
|
+
const feedback = typeof answer.feedback === 'string'
|
|
158
|
+
? answer.feedback.trim().slice(0, BRIDGE_LIMITS.maxPlanFeedbackChars)
|
|
159
|
+
: '';
|
|
160
|
+
if (decision === 'revise' && feedback.length === 0) {
|
|
161
|
+
return { kind: 'planDecision', decision: 'dismissed', feedback: '' };
|
|
162
|
+
}
|
|
163
|
+
return { kind: 'planDecision', decision, feedback };
|
|
164
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every bound the bridge protocol has, in one object (headless-host-bridge I-6).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* ONE AUTHORITY, NO LITERALS ELSEWHERE. "How long may a decision wait", "how many
|
|
7
|
+
* caller tools may exist", "how many bytes may one frame be" are all answers a
|
|
8
|
+
* caller can be told (`aragon info --json`) and a validator has to enforce. Two
|
|
9
|
+
* copies of a number are two answers the moment one of them is edited, and the
|
|
10
|
+
* pair that disagrees is always discovered in the field.
|
|
11
|
+
*
|
|
12
|
+
* STRUCTURAL, NOT POLICY. These are what a protocol frame can physically carry,
|
|
13
|
+
* which is the same distinction `config/schema.ts` draws for user-tunable keys
|
|
14
|
+
* (`.claude-index/index.md`, "limits.ts is structural"). Nothing here is
|
|
15
|
+
* persisted and nothing here is settable from `config.json`: the two timeouts
|
|
16
|
+
* have flags because they are per-invocation budgets, and every other number is
|
|
17
|
+
* a property of the wire format.
|
|
18
|
+
*/
|
|
19
|
+
export declare const BRIDGE_LIMITS: {
|
|
20
|
+
/** One NDJSON frame. A larger line is skipped, never fatal. */
|
|
21
|
+
readonly maxFrameBytes: 1048576;
|
|
22
|
+
/** Declared caller tools in one `tool_register` frame. */
|
|
23
|
+
readonly maxCallerTools: 32;
|
|
24
|
+
readonly maxCallerToolNameChars: 40;
|
|
25
|
+
readonly maxCallerToolSchemaBytes: 16384;
|
|
26
|
+
readonly maxCallerToolDescriptionChars: 1024;
|
|
27
|
+
/** Mirrors Core's `ToolExecutor` default `maxOutputSize` (executor.ts:42). */
|
|
28
|
+
readonly maxCallerToolResultBytes: 100000;
|
|
29
|
+
/** Concurrent in-flight caller tools. Over the cap, the call is refused. */
|
|
30
|
+
readonly maxOutstandingCallerTools: 8;
|
|
31
|
+
/** Concurrent unanswered decisions, lead plus every subagent. */
|
|
32
|
+
readonly maxOutstandingDecisions: 8;
|
|
33
|
+
/**
|
|
34
|
+
* Recently settled request ids remembered so a REPEATED answer can be answered
|
|
35
|
+
* as `bridge_duplicate_response` instead of `bridge_unknown_request`.
|
|
36
|
+
*
|
|
37
|
+
* The design dropped the 512-entry decision ledger (review P2-7) on the grounds
|
|
38
|
+
* that the pending table's settle-once already covered duplicate detection. That
|
|
39
|
+
* is true only while the entry is still IN the table: this table removes an
|
|
40
|
+
* entry the moment it settles, so without a small ring the very first repeat
|
|
41
|
+
* would be reported as an unknown request - a different code, and a misleading
|
|
42
|
+
* one, for a caller that answered twice. The ring has exactly one consumer (the
|
|
43
|
+
* duplicate branch in `pending.ts`), which is the condition P2-7 set for keeping
|
|
44
|
+
* any such state at all.
|
|
45
|
+
*/
|
|
46
|
+
readonly maxSettledIds: 256;
|
|
47
|
+
/** Default and hard ceiling for one decision's deadline. */
|
|
48
|
+
readonly defaultDecisionTimeoutMs: 300000;
|
|
49
|
+
readonly maxDecisionTimeoutMs: 1800000;
|
|
50
|
+
/** Caller tool timeouts: the flag's default, and its hard ceiling. */
|
|
51
|
+
readonly defaultCallerToolTimeoutMs: 60000;
|
|
52
|
+
readonly maxCallerToolTimeoutMs: 600000;
|
|
53
|
+
/**
|
|
54
|
+
* Strings the caller supplies that end up in front of the model.
|
|
55
|
+
*
|
|
56
|
+
* BOUNDED BECAUSE THEY ARE MODEL-VISIBLE, not because they are expensive: a
|
|
57
|
+
* denial reason and plan feedback both reach the transcript, so an unbounded
|
|
58
|
+
* one is an unbounded line in every subsequent provider request.
|
|
59
|
+
*/
|
|
60
|
+
readonly maxDenyReasonChars: 500;
|
|
61
|
+
readonly maxPlanFeedbackChars: 2000;
|
|
62
|
+
readonly maxCustomAnswerChars: 2000;
|
|
63
|
+
/** `tool_invoke.input` is echoed back so the caller can act on it. */
|
|
64
|
+
readonly maxInvokeInputChars: 16384;
|
|
65
|
+
/** stdout backpressure watermarks. See `writer.ts`. */
|
|
66
|
+
readonly softPendingBytes: 4194304;
|
|
67
|
+
readonly hardPendingBytes: 16777216;
|
|
68
|
+
};
|