@gajae-code/coding-agent 0.3.2 → 0.4.1
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 +39 -0
- package/dist/types/config/model-registry.d.ts +17 -10
- package/dist/types/config/models-config-schema.d.ts +37 -0
- package/dist/types/config/settings-schema.d.ts +5 -0
- package/dist/types/edit/diff.d.ts +16 -0
- package/dist/types/edit/modes/replace.d.ts +7 -0
- package/dist/types/extensibility/gjc-plugins/activation.d.ts +14 -0
- package/dist/types/extensibility/gjc-plugins/index.d.ts +9 -0
- package/dist/types/extensibility/gjc-plugins/injection.d.ts +31 -0
- package/dist/types/extensibility/gjc-plugins/loader.d.ts +3 -0
- package/dist/types/extensibility/gjc-plugins/paths.d.ts +8 -0
- package/dist/types/extensibility/gjc-plugins/schema.d.ts +3 -0
- package/dist/types/extensibility/gjc-plugins/state.d.ts +9 -0
- package/dist/types/extensibility/gjc-plugins/tools.d.ts +8 -0
- package/dist/types/extensibility/gjc-plugins/types.d.ts +64 -0
- package/dist/types/extensibility/gjc-plugins/validation.d.ts +4 -0
- package/dist/types/extensibility/skills.d.ts +9 -1
- package/dist/types/gjc-runtime/state-runtime.d.ts +22 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -2
- package/dist/types/harness-control-plane/storage.d.ts +7 -0
- package/dist/types/lsp/client.d.ts +1 -0
- package/dist/types/modes/bridge/bridge-mode.d.ts +2 -0
- package/dist/types/modes/prompt-action-autocomplete.d.ts +2 -2
- package/dist/types/modes/rpc/rpc-client.d.ts +19 -1
- package/dist/types/modes/rpc/rpc-types.d.ts +179 -2
- package/dist/types/modes/shared/agent-wire/approval-gate.d.ts +57 -0
- package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +16 -1
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +47 -0
- package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +7 -0
- package/dist/types/modes/shared/agent-wire/handshake.d.ts +11 -1
- package/dist/types/modes/shared/agent-wire/protocol.d.ts +3 -1
- package/dist/types/modes/shared/agent-wire/responses.d.ts +1 -1
- package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +27 -0
- package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +68 -0
- package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +161 -0
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +61 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +114 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-schema.d.ts +39 -0
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
- package/dist/types/sdk.d.ts +7 -0
- package/dist/types/session/agent-session.d.ts +10 -0
- package/dist/types/session/blob-store.d.ts +17 -0
- package/dist/types/session/messages.d.ts +3 -0
- package/dist/types/session/session-storage.d.ts +6 -0
- package/dist/types/skill-state/active-state.d.ts +13 -0
- package/dist/types/thinking.d.ts +3 -2
- package/dist/types/tools/index.d.ts +3 -0
- package/package.json +9 -7
- package/src/cli.ts +14 -0
- package/src/commands/harness.ts +192 -7
- package/src/commands/ultragoal.ts +1 -21
- package/src/config/model-equivalence.ts +1 -1
- package/src/config/model-registry.ts +32 -5
- package/src/config/models-config-schema.ts +7 -2
- package/src/config/settings-schema.ts +4 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +19 -23
- package/src/defaults/gjc/skills/ralplan/SKILL.md +7 -7
- package/src/discovery/claude-plugins.ts +25 -5
- package/src/edit/diff.ts +64 -1
- package/src/edit/modes/replace.ts +60 -2
- package/src/extensibility/gjc-plugins/activation.ts +87 -0
- package/src/extensibility/gjc-plugins/index.ts +9 -0
- package/src/extensibility/gjc-plugins/injection.ts +114 -0
- package/src/extensibility/gjc-plugins/loader.ts +131 -0
- package/src/extensibility/gjc-plugins/paths.ts +66 -0
- package/src/extensibility/gjc-plugins/schema.ts +79 -0
- package/src/extensibility/gjc-plugins/state.ts +29 -0
- package/src/extensibility/gjc-plugins/tools.ts +47 -0
- package/src/extensibility/gjc-plugins/types.ts +97 -0
- package/src/extensibility/gjc-plugins/validation.ts +76 -0
- package/src/extensibility/skills.ts +39 -7
- package/src/gjc-runtime/state-runtime.ts +93 -2
- package/src/gjc-runtime/state-writer.ts +17 -1
- package/src/gjc-runtime/ultragoal-runtime.ts +76 -121
- package/src/gjc-runtime/workflow-manifest.generated.json +5 -0
- package/src/gjc-runtime/workflow-manifest.ts +2 -2
- package/src/harness-control-plane/storage.ts +144 -2
- package/src/hashline/hash.ts +23 -0
- package/src/hooks/skill-state.ts +2 -0
- package/src/internal-urls/docs-index.generated.ts +5 -5
- package/src/lsp/client.ts +7 -0
- package/src/modes/acp/acp-agent.ts +25 -2
- package/src/modes/bridge/bridge-mode.ts +124 -2
- package/src/modes/controllers/input-controller.ts +14 -2
- package/src/modes/prompt-action-autocomplete.ts +49 -10
- package/src/modes/rpc/rpc-client.ts +79 -3
- package/src/modes/rpc/rpc-mode.ts +67 -0
- package/src/modes/rpc/rpc-types.ts +224 -2
- package/src/modes/shared/agent-wire/approval-gate.ts +151 -0
- package/src/modes/shared/agent-wire/command-dispatch.ts +97 -4
- package/src/modes/shared/agent-wire/command-validation.ts +25 -1
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +222 -0
- package/src/modes/shared/agent-wire/event-envelope.ts +13 -0
- package/src/modes/shared/agent-wire/handshake.ts +43 -3
- package/src/modes/shared/agent-wire/protocol.ts +7 -0
- package/src/modes/shared/agent-wire/responses.ts +2 -2
- package/src/modes/shared/agent-wire/scopes.ts +2 -0
- package/src/modes/shared/agent-wire/unattended-action-policy.ts +341 -0
- package/src/modes/shared/agent-wire/unattended-audit.ts +175 -0
- package/src/modes/shared/agent-wire/unattended-run-controller.ts +406 -0
- package/src/modes/shared/agent-wire/unattended-session.ts +180 -0
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +324 -0
- package/src/modes/shared/agent-wire/workflow-gate-schema.ts +331 -0
- package/src/modes/theme/theme.ts +6 -0
- package/src/prompts/system/system-prompt.md +9 -0
- package/src/runtime-mcp/client.ts +7 -4
- package/src/runtime-mcp/manager.ts +45 -13
- package/src/runtime-mcp/transports/http.ts +40 -14
- package/src/runtime-mcp/transports/stdio.ts +11 -10
- package/src/sdk.ts +47 -0
- package/src/session/agent-session.ts +211 -2
- package/src/session/blob-store.ts +84 -0
- package/src/session/messages.ts +3 -0
- package/src/session/session-manager.ts +390 -33
- package/src/session/session-storage.ts +26 -0
- package/src/setup/provider-onboarding.ts +2 -2
- package/src/skill-state/active-state.ts +89 -1
- package/src/task/discovery.ts +7 -1
- package/src/task/executor.ts +16 -2
- package/src/thinking.ts +8 -2
- package/src/tools/ask.ts +39 -9
- package/src/tools/index.ts +3 -0
- package/src/tools/skill.ts +15 -3
- package/src/utils/edit-mode.ts +1 -1
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unattended run controller (#318).
|
|
3
|
+
*
|
|
4
|
+
* The single required owner of negotiated unattended state. Entering unattended
|
|
5
|
+
* mode is fail-closed: a controller can only be created via {@link
|
|
6
|
+
* UnattendedRunController.negotiate}, which rejects a missing/partial budget, an
|
|
7
|
+
* invalid declaration, or a provider that cannot account for tokens/cost.
|
|
8
|
+
*
|
|
9
|
+
* The controller owns budget accounting across metrics and phases. Scope/action
|
|
10
|
+
* authorization (#319) and the durable audit trail (#320) layer onto the same
|
|
11
|
+
* controller; this slice wires the budget floor and abort coordination.
|
|
12
|
+
*
|
|
13
|
+
* Attended mode never constructs a controller, so by construction the attended
|
|
14
|
+
* path is unaffected by everything here.
|
|
15
|
+
*/
|
|
16
|
+
import type {
|
|
17
|
+
RpcActionDenied,
|
|
18
|
+
RpcBudgetExceeded,
|
|
19
|
+
RpcBudgetMetric,
|
|
20
|
+
RpcScopeDenied,
|
|
21
|
+
RpcUnattendedActionClass,
|
|
22
|
+
RpcUnattendedBudget,
|
|
23
|
+
RpcUnattendedDeclaration,
|
|
24
|
+
RpcUnattendedRefusalCode,
|
|
25
|
+
} from "../../rpc/rpc-types";
|
|
26
|
+
import type { BridgeCommandScope } from "./scopes";
|
|
27
|
+
import { actionClassForScope, classifyBashAction } from "./unattended-action-policy";
|
|
28
|
+
|
|
29
|
+
/** Coordinated abort surfaces invoked exactly once on a budget breach / abort. */
|
|
30
|
+
export interface UnattendedAbortHooks {
|
|
31
|
+
abortModelStream?(): void | Promise<void>;
|
|
32
|
+
abortBash?(): void | Promise<void>;
|
|
33
|
+
cancelHostTools?(reason: string): void | Promise<void>;
|
|
34
|
+
cancelHostUris?(reason: string): void | Promise<void>;
|
|
35
|
+
stopWorkflow?(reason: string): void | Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type UnattendedAuditEvent =
|
|
39
|
+
| { event: "unattended_negotiated"; run_id: string; actor: string }
|
|
40
|
+
| { event: "budget_exceeded"; payload: RpcBudgetExceeded }
|
|
41
|
+
| { event: "unattended_aborted"; run_id: string; reason: string }
|
|
42
|
+
| { event: "unattended_abort_settled"; run_id: string; status: "aborted" | "abort_failed"; failures: number }
|
|
43
|
+
| { event: "scope_denied"; payload: RpcScopeDenied }
|
|
44
|
+
| { event: "action_denied"; payload: RpcActionDenied };
|
|
45
|
+
|
|
46
|
+
export interface NegotiateContext {
|
|
47
|
+
runId: string;
|
|
48
|
+
sessionId?: string;
|
|
49
|
+
audit?(event: UnattendedAuditEvent): void;
|
|
50
|
+
abortHooks?: UnattendedAbortHooks;
|
|
51
|
+
/**
|
|
52
|
+
* Whether the active provider can report token usage and cost. Unattended
|
|
53
|
+
* mode rejects providers without this accounting up front (fail-closed).
|
|
54
|
+
*/
|
|
55
|
+
providerSupportsTokenCostMetrics?: boolean;
|
|
56
|
+
/** Injectable clock for deterministic tests. Defaults to Date.now. */
|
|
57
|
+
now?(): number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class UnattendedNegotiationError extends Error {
|
|
61
|
+
constructor(
|
|
62
|
+
readonly code: RpcUnattendedRefusalCode,
|
|
63
|
+
message: string,
|
|
64
|
+
) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.name = "UnattendedNegotiationError";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class UnattendedBudgetExceededError extends Error {
|
|
71
|
+
constructor(readonly payload: RpcBudgetExceeded) {
|
|
72
|
+
super(`budget_exceeded: ${payload.metric} ${payload.observed}/${payload.limit} at ${payload.phase}`);
|
|
73
|
+
this.name = "UnattendedBudgetExceededError";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Thrown when a provider/tool reports a non-finite usage value (fail-closed). */
|
|
78
|
+
export class UnattendedAccountingError extends Error {
|
|
79
|
+
constructor(
|
|
80
|
+
readonly metric: RpcBudgetMetric,
|
|
81
|
+
readonly phase: string,
|
|
82
|
+
value: unknown,
|
|
83
|
+
) {
|
|
84
|
+
super(`non-finite ${metric} usage (${String(value)}) at ${phase}`);
|
|
85
|
+
this.name = "UnattendedAccountingError";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Thrown when a command's coarse scope is outside the declared allowlist (#319). */
|
|
90
|
+
export class ScopeDeniedError extends Error {
|
|
91
|
+
constructor(readonly payload: RpcScopeDenied) {
|
|
92
|
+
super(`scope_denied: ${payload.scope}${payload.command ? ` (${payload.command})` : ""}`);
|
|
93
|
+
this.name = "ScopeDeniedError";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Thrown when an action class is outside the declared allowlist (default-deny, #319). */
|
|
98
|
+
export class ActionDeniedError extends Error {
|
|
99
|
+
constructor(readonly payload: RpcActionDenied) {
|
|
100
|
+
super(`action_denied: ${payload.action}${payload.command ? ` (${payload.command})` : ""}`);
|
|
101
|
+
this.name = "ActionDeniedError";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface Usage {
|
|
106
|
+
tokens: number;
|
|
107
|
+
toolCalls: number;
|
|
108
|
+
costUsd: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isPositiveFiniteNumber(v: unknown): v is number {
|
|
112
|
+
return typeof v === "number" && Number.isFinite(v) && v > 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function validateBudget(budget: unknown): RpcUnattendedBudget {
|
|
116
|
+
if (typeof budget !== "object" || budget === null) {
|
|
117
|
+
throw new UnattendedNegotiationError("incomplete_budget", "budget declaration is required for unattended mode");
|
|
118
|
+
}
|
|
119
|
+
const b = budget as Record<string, unknown>;
|
|
120
|
+
const fields: Array<keyof RpcUnattendedBudget> = [
|
|
121
|
+
"max_tokens",
|
|
122
|
+
"max_tool_calls",
|
|
123
|
+
"max_wall_time_ms",
|
|
124
|
+
"max_cost_usd",
|
|
125
|
+
];
|
|
126
|
+
for (const f of fields) {
|
|
127
|
+
if (!isPositiveFiniteNumber(b[f])) {
|
|
128
|
+
throw new UnattendedNegotiationError("incomplete_budget", `budget.${f} must be a positive finite number`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
max_tokens: b.max_tokens as number,
|
|
133
|
+
max_tool_calls: b.max_tool_calls as number,
|
|
134
|
+
max_wall_time_ms: b.max_wall_time_ms as number,
|
|
135
|
+
max_cost_usd: b.max_cost_usd as number,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export class UnattendedRunController {
|
|
140
|
+
readonly runId: string;
|
|
141
|
+
readonly sessionId?: string;
|
|
142
|
+
readonly actor: string;
|
|
143
|
+
readonly budget: RpcUnattendedBudget;
|
|
144
|
+
readonly scopes: ReadonlySet<string>;
|
|
145
|
+
readonly actionAllowlist: ReadonlySet<string>;
|
|
146
|
+
|
|
147
|
+
private readonly usage: Usage = { tokens: 0, toolCalls: 0, costUsd: 0 };
|
|
148
|
+
private readonly startedAt: number;
|
|
149
|
+
private readonly now: () => number;
|
|
150
|
+
private readonly audit?: (event: UnattendedAuditEvent) => void;
|
|
151
|
+
private readonly abortHooks: UnattendedAbortHooks;
|
|
152
|
+
private aborted = false;
|
|
153
|
+
private abortPromise?: Promise<void>;
|
|
154
|
+
|
|
155
|
+
private constructor(declaration: RpcUnattendedDeclaration, ctx: NegotiateContext, budget: RpcUnattendedBudget) {
|
|
156
|
+
this.runId = ctx.runId;
|
|
157
|
+
this.sessionId = ctx.sessionId;
|
|
158
|
+
this.actor = declaration.actor;
|
|
159
|
+
this.budget = budget;
|
|
160
|
+
this.scopes = new Set(declaration.scopes);
|
|
161
|
+
this.actionAllowlist = new Set(declaration.action_allowlist);
|
|
162
|
+
this.now = ctx.now ?? Date.now;
|
|
163
|
+
this.audit = ctx.audit;
|
|
164
|
+
this.abortHooks = ctx.abortHooks ?? {};
|
|
165
|
+
this.startedAt = this.now();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Fail-closed entry: validate the declaration + budget, or throw. */
|
|
169
|
+
static negotiate(declaration: unknown, ctx: NegotiateContext): UnattendedRunController {
|
|
170
|
+
if (typeof declaration !== "object" || declaration === null) {
|
|
171
|
+
throw new UnattendedNegotiationError("invalid_unattended_declaration", "declaration is required");
|
|
172
|
+
}
|
|
173
|
+
const d = declaration as Record<string, unknown>;
|
|
174
|
+
if (typeof d.actor !== "string" || d.actor.trim() === "") {
|
|
175
|
+
throw new UnattendedNegotiationError("invalid_unattended_declaration", "declaration.actor is required");
|
|
176
|
+
}
|
|
177
|
+
if (!Array.isArray(d.scopes) || !d.scopes.every(s => typeof s === "string")) {
|
|
178
|
+
throw new UnattendedNegotiationError("invalid_unattended_declaration", "declaration.scopes must be string[]");
|
|
179
|
+
}
|
|
180
|
+
if (!Array.isArray(d.action_allowlist) || !d.action_allowlist.every(s => typeof s === "string")) {
|
|
181
|
+
throw new UnattendedNegotiationError(
|
|
182
|
+
"invalid_unattended_declaration",
|
|
183
|
+
"declaration.action_allowlist must be string[]",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const budget = validateBudget(d.budget);
|
|
187
|
+
// Reject providers that cannot account for tokens/cost (fail-closed): require
|
|
188
|
+
// an explicit positive capability signal — omitted/unknown is refused too.
|
|
189
|
+
if (ctx.providerSupportsTokenCostMetrics !== true) {
|
|
190
|
+
throw new UnattendedNegotiationError(
|
|
191
|
+
"unsupported_budget_metric",
|
|
192
|
+
"unattended mode requires an explicit provider token/cost accounting capability",
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
const controller = new UnattendedRunController(d as unknown as RpcUnattendedDeclaration, ctx, budget);
|
|
196
|
+
ctx.audit?.({ event: "unattended_negotiated", run_id: ctx.runId, actor: controller.actor });
|
|
197
|
+
return controller;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
get isAborted(): boolean {
|
|
201
|
+
return this.aborted;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
usageSnapshot(): Readonly<Usage> & { wallTimeMs: number } {
|
|
205
|
+
return { ...this.usage, wallTimeMs: this.now() - this.startedAt };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
remainingWallTimeMs(): number {
|
|
209
|
+
return Math.max(0, this.budget.max_wall_time_ms - (this.now() - this.startedAt));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Authorize a command's coarse scope against the declared allowlist. Throws
|
|
214
|
+
* ScopeDeniedError (pre-side-effect) when the scope was not declared.
|
|
215
|
+
*/
|
|
216
|
+
authorizeScope(scope: BridgeCommandScope, command?: string): void {
|
|
217
|
+
if (this.scopes.has(scope)) return;
|
|
218
|
+
const payload: RpcScopeDenied = {
|
|
219
|
+
code: "scope_denied",
|
|
220
|
+
scope,
|
|
221
|
+
command,
|
|
222
|
+
run_id: this.runId,
|
|
223
|
+
session_id: this.sessionId,
|
|
224
|
+
pre_side_effect: true,
|
|
225
|
+
};
|
|
226
|
+
this.audit?.({ event: "scope_denied", payload });
|
|
227
|
+
throw new ScopeDeniedError(payload);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Authorize an action class against the declared allowlist. Default-deny: any
|
|
232
|
+
* class not explicitly declared is rejected with ActionDeniedError before the
|
|
233
|
+
* side effect runs.
|
|
234
|
+
*/
|
|
235
|
+
authorizeAction(action: RpcUnattendedActionClass, command?: string): void {
|
|
236
|
+
if (this.actionAllowlist.has(action)) return;
|
|
237
|
+
const payload: RpcActionDenied = {
|
|
238
|
+
code: "action_denied",
|
|
239
|
+
action,
|
|
240
|
+
command,
|
|
241
|
+
run_id: this.runId,
|
|
242
|
+
session_id: this.sessionId,
|
|
243
|
+
pre_side_effect: true,
|
|
244
|
+
};
|
|
245
|
+
this.audit?.({ event: "action_denied", payload });
|
|
246
|
+
throw new ActionDeniedError(payload);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Classify a bash command and authorize the resulting action class BEFORE the
|
|
251
|
+
* command is executed. Returns the classified action class on success.
|
|
252
|
+
*/
|
|
253
|
+
authorizeBash(command: string): RpcUnattendedActionClass {
|
|
254
|
+
this.authorizeScope("bash", command);
|
|
255
|
+
const action = classifyBashAction(command);
|
|
256
|
+
this.authorizeAction(action, command);
|
|
257
|
+
return action;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Convenience: map a coarse scope to its `command.<scope>` action class. */
|
|
261
|
+
static actionClassForScope = actionClassForScope;
|
|
262
|
+
|
|
263
|
+
/** Pre-turn estimate: refuse to start a turn that would obviously breach. */
|
|
264
|
+
preTurnEstimate(estimate: { tokens?: number; costUsd?: number }): void {
|
|
265
|
+
this.checkWallTime("pre-turn estimate");
|
|
266
|
+
if (estimate.tokens !== undefined) {
|
|
267
|
+
if (!Number.isFinite(estimate.tokens)) {
|
|
268
|
+
void this.fireAbort("accounting:tokens");
|
|
269
|
+
throw new UnattendedAccountingError("tokens", "pre-turn estimate", estimate.tokens);
|
|
270
|
+
}
|
|
271
|
+
if (this.usage.tokens + estimate.tokens > this.budget.max_tokens) {
|
|
272
|
+
this.breach("tokens", this.budget.max_tokens, this.usage.tokens + estimate.tokens, "pre-turn estimate");
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (estimate.costUsd !== undefined) {
|
|
276
|
+
if (!Number.isFinite(estimate.costUsd)) {
|
|
277
|
+
void this.fireAbort("accounting:cost");
|
|
278
|
+
throw new UnattendedAccountingError("cost", "pre-turn estimate", estimate.costUsd);
|
|
279
|
+
}
|
|
280
|
+
if (this.usage.costUsd + estimate.costUsd > this.budget.max_cost_usd) {
|
|
281
|
+
this.breach("cost", this.budget.max_cost_usd, this.usage.costUsd + estimate.costUsd, "pre-turn estimate");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Reserve one tool-call unit BEFORE any side effect; breach if it would exceed. */
|
|
287
|
+
preflightToolCall(phase = "tool-call preflight"): void {
|
|
288
|
+
this.checkWallTime(phase);
|
|
289
|
+
if (this.usage.toolCalls + 1 > this.budget.max_tool_calls) {
|
|
290
|
+
this.breach("tool_calls", this.budget.max_tool_calls, this.usage.toolCalls + 1, phase);
|
|
291
|
+
}
|
|
292
|
+
this.usage.toolCalls += 1;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Post-turn reconciliation of actual token usage. Fails closed on non-finite. */
|
|
296
|
+
recordTokens(tokens: number, phase = "post-turn reconciliation"): void {
|
|
297
|
+
if (!Number.isFinite(tokens)) {
|
|
298
|
+
void this.fireAbort(`accounting:tokens`);
|
|
299
|
+
throw new UnattendedAccountingError("tokens", phase, tokens);
|
|
300
|
+
}
|
|
301
|
+
this.usage.tokens += Math.max(0, tokens);
|
|
302
|
+
if (this.usage.tokens > this.budget.max_tokens) {
|
|
303
|
+
this.breach("tokens", this.budget.max_tokens, this.usage.tokens, phase);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
recordCost(costUsd: number, phase = "post-turn reconciliation"): void {
|
|
308
|
+
if (!Number.isFinite(costUsd)) {
|
|
309
|
+
void this.fireAbort(`accounting:cost`);
|
|
310
|
+
throw new UnattendedAccountingError("cost", phase, costUsd);
|
|
311
|
+
}
|
|
312
|
+
this.usage.costUsd += Math.max(0, costUsd);
|
|
313
|
+
if (this.usage.costUsd > this.budget.max_cost_usd) {
|
|
314
|
+
this.breach("cost", this.budget.max_cost_usd, this.usage.costUsd, phase);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Combined post-turn reconciliation. */
|
|
319
|
+
reconcile(actual: { tokens?: number; costUsd?: number }, phase = "post-turn reconciliation"): void {
|
|
320
|
+
if (actual.tokens !== undefined) this.recordTokens(actual.tokens, phase);
|
|
321
|
+
if (actual.costUsd !== undefined) this.recordCost(actual.costUsd, phase);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Wall-time check; call before/inside long operations. */
|
|
325
|
+
checkWallTime(phase = "wall-time"): void {
|
|
326
|
+
const elapsed = this.now() - this.startedAt;
|
|
327
|
+
if (elapsed > this.budget.max_wall_time_ms) {
|
|
328
|
+
this.breach("wall_time", this.budget.max_wall_time_ms, elapsed, phase);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private breach(metric: RpcBudgetMetric, limit: number, observed: number, phase: string): never {
|
|
333
|
+
// Initiate the (idempotent, awaitable) abort. breach() throws synchronously,
|
|
334
|
+
// so the payload reports `aborting`; the settled status (aborted/abort_failed)
|
|
335
|
+
// is emitted as an unattended_abort_settled audit event once hooks complete.
|
|
336
|
+
void this.fireAbort(`budget_exceeded:${metric}`);
|
|
337
|
+
const abortStatus: RpcBudgetExceeded["abort_status"] = "aborting";
|
|
338
|
+
const payload: RpcBudgetExceeded = {
|
|
339
|
+
code: "budget_exceeded",
|
|
340
|
+
metric,
|
|
341
|
+
limit,
|
|
342
|
+
observed,
|
|
343
|
+
phase,
|
|
344
|
+
run_id: this.runId,
|
|
345
|
+
session_id: this.sessionId,
|
|
346
|
+
abort_status: abortStatus,
|
|
347
|
+
};
|
|
348
|
+
this.audit?.({ event: "budget_exceeded", payload });
|
|
349
|
+
throw new UnattendedBudgetExceededError(payload);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Idempotent abort: runs every abort hook at most once. Safe to call from the
|
|
354
|
+
* synchronous breach path (which does not await) and from callers that do; both
|
|
355
|
+
* share a single abort promise so hook completion can be awaited deterministically.
|
|
356
|
+
*/
|
|
357
|
+
async abort(reason: string): Promise<void> {
|
|
358
|
+
await this.fireAbort(reason);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Returns the in-flight/settled abort completion once aborting has begun. */
|
|
362
|
+
get abortCompletion(): Promise<void> | undefined {
|
|
363
|
+
return this.abortPromise;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private fireAbort(reason: string): Promise<void> {
|
|
367
|
+
if (this.abortPromise) return this.abortPromise;
|
|
368
|
+
this.aborted = true;
|
|
369
|
+
this.abortPromise = this.runAbortHooks().then(failures => {
|
|
370
|
+
this.audit?.({ event: "unattended_aborted", run_id: this.runId, reason });
|
|
371
|
+
this.audit?.({
|
|
372
|
+
event: "unattended_abort_settled",
|
|
373
|
+
run_id: this.runId,
|
|
374
|
+
status: failures === 0 ? "aborted" : "abort_failed",
|
|
375
|
+
failures,
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
return this.abortPromise;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Invoke every configured abort hook exactly once with per-hook isolation, so a
|
|
383
|
+
* failing hook never prevents the other cancellation surfaces from running.
|
|
384
|
+
* Returns the number of hooks that rejected.
|
|
385
|
+
*/
|
|
386
|
+
private async runAbortHooks(): Promise<number> {
|
|
387
|
+
const calls: Array<Promise<unknown>> = [];
|
|
388
|
+
const run = (fn: (() => void | Promise<void>) | undefined) => {
|
|
389
|
+
if (!fn) return;
|
|
390
|
+
// Invoke synchronously so the hook's synchronous prefix runs immediately,
|
|
391
|
+
// but capture sync throws and async rejections so allSettled isolates them.
|
|
392
|
+
try {
|
|
393
|
+
calls.push(Promise.resolve(fn()));
|
|
394
|
+
} catch (err) {
|
|
395
|
+
calls.push(Promise.reject(err));
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
run(this.abortHooks.abortModelStream);
|
|
399
|
+
run(this.abortHooks.abortBash);
|
|
400
|
+
run(() => this.abortHooks.cancelHostTools?.("unattended abort"));
|
|
401
|
+
run(() => this.abortHooks.cancelHostUris?.("unattended abort"));
|
|
402
|
+
run(() => this.abortHooks.stopWorkflow?.("unattended abort"));
|
|
403
|
+
const results = await Promise.allSettled(calls);
|
|
404
|
+
return results.filter(r => r.status === "rejected").length;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-level unattended control plane (#323 / G011 emission side).
|
|
3
|
+
*
|
|
4
|
+
* Bridges the gate EMISSION side (skill runtimes / ask tool emitting gates) to
|
|
5
|
+
* the gate ANSWER side (the external agent's `workflow_gate_response` over RPC):
|
|
6
|
+
*
|
|
7
|
+
* - `emitGate(input)` opens a gate on the durable broker, emits the gate frame
|
|
8
|
+
* to the transport, and returns a promise that resolves with the agent's
|
|
9
|
+
* answer once it arrives.
|
|
10
|
+
* - `resolveGate(response)` (called from RPC dispatch) validates + resolves the
|
|
11
|
+
* gate on the broker; the broker's `advance` hook resolves the pending
|
|
12
|
+
* `emitGate` promise with the answer.
|
|
13
|
+
*
|
|
14
|
+
* Also implements the dispatch-facing {@link RpcUnattendedControlPlane} so the
|
|
15
|
+
* RPC server can route `negotiate_unattended` + `workflow_gate_response` here.
|
|
16
|
+
*/
|
|
17
|
+
import type {
|
|
18
|
+
RpcCommand,
|
|
19
|
+
RpcUnattendedAccepted,
|
|
20
|
+
RpcUnattendedDeclaration,
|
|
21
|
+
RpcWorkflowGate,
|
|
22
|
+
RpcWorkflowGateResolution,
|
|
23
|
+
RpcWorkflowGateResponse,
|
|
24
|
+
} from "../../rpc/rpc-types";
|
|
25
|
+
import type { RpcUnattendedControlPlane } from "./command-dispatch";
|
|
26
|
+
import { scopeForRpcCommand } from "./scopes";
|
|
27
|
+
import {
|
|
28
|
+
type UnattendedAbortHooks,
|
|
29
|
+
type UnattendedAuditEvent,
|
|
30
|
+
UnattendedRunController,
|
|
31
|
+
} from "./unattended-run-controller";
|
|
32
|
+
import { type GateStore, MemoryGateStore, type OpenGateInput, WorkflowGateBroker } from "./workflow-gate-broker";
|
|
33
|
+
|
|
34
|
+
/** Minimal surface a skill runtime / ask tool uses to emit a gate and await its answer. */
|
|
35
|
+
export interface WorkflowGateEmitter {
|
|
36
|
+
/** True only when unattended mode has been negotiated. */
|
|
37
|
+
isUnattended(): boolean;
|
|
38
|
+
/** Open + emit a gate; resolves with the agent's answer (from workflow_gate_response). */
|
|
39
|
+
emitGate(input: OpenGateInput): Promise<unknown>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface UnattendedSessionOptions {
|
|
43
|
+
runId: string;
|
|
44
|
+
sessionId?: string;
|
|
45
|
+
/** Emit a workflow_gate frame to the transport so the agent receives it. */
|
|
46
|
+
emitFrame: (gate: RpcWorkflowGate) => void;
|
|
47
|
+
/** Durable gate store; defaults to in-memory. */
|
|
48
|
+
store?: GateStore;
|
|
49
|
+
/** Audit sink for controller + gate events. */
|
|
50
|
+
audit?: (event: UnattendedAuditEvent | { event: string; [k: string]: unknown }) => void;
|
|
51
|
+
abortHooks?: UnattendedAbortHooks;
|
|
52
|
+
/** Whether the active provider reports token/cost usage (fail-closed when false/omitted). */
|
|
53
|
+
providerSupportsTokenCostMetrics?: boolean;
|
|
54
|
+
/** Snapshot live cumulative usage after a dispatch or turn, for budget reconciliation. */
|
|
55
|
+
getUsageSnapshot?: () => { tokens?: number; costUsd?: number };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class UnattendedSessionControlPlane implements RpcUnattendedControlPlane, WorkflowGateEmitter {
|
|
59
|
+
#controller: UnattendedRunController | undefined;
|
|
60
|
+
#broker: WorkflowGateBroker | undefined;
|
|
61
|
+
readonly #pending = new Map<string, { resolve: (answer: unknown) => void; reject: (err: Error) => void }>();
|
|
62
|
+
readonly #earlyAnswers = new Map<string, unknown>();
|
|
63
|
+
|
|
64
|
+
constructor(private readonly opts: UnattendedSessionOptions) {}
|
|
65
|
+
|
|
66
|
+
isUnattended(): boolean {
|
|
67
|
+
return this.#controller !== undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get controller(): UnattendedRunController | undefined {
|
|
71
|
+
return this.#controller;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
negotiate(declaration: RpcUnattendedDeclaration): RpcUnattendedAccepted {
|
|
75
|
+
const controller = UnattendedRunController.negotiate(declaration, {
|
|
76
|
+
runId: this.opts.runId,
|
|
77
|
+
sessionId: this.opts.sessionId,
|
|
78
|
+
audit: this.opts.audit,
|
|
79
|
+
abortHooks: {
|
|
80
|
+
...this.opts.abortHooks,
|
|
81
|
+
// On abort (e.g. wall-time / budget breach) reject any gate still
|
|
82
|
+
// awaiting an answer, so emitGate cannot hang forever.
|
|
83
|
+
stopWorkflow: async reason => {
|
|
84
|
+
await this.opts.abortHooks?.stopWorkflow?.(reason);
|
|
85
|
+
this.#rejectAllPending(new Error(`unattended run aborted: ${reason}`));
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
providerSupportsTokenCostMetrics: this.opts.providerSupportsTokenCostMetrics ?? true,
|
|
89
|
+
});
|
|
90
|
+
this.#controller = controller;
|
|
91
|
+
this.#broker = new WorkflowGateBroker(this.opts.runId, this.opts.store ?? new MemoryGateStore(), {
|
|
92
|
+
emit: gate => this.opts.emitFrame(gate),
|
|
93
|
+
audit: e => this.opts.audit?.(e),
|
|
94
|
+
advance: (gate, answer) => {
|
|
95
|
+
const pending = this.#pending.get(gate.gate_id);
|
|
96
|
+
if (pending) {
|
|
97
|
+
this.#pending.delete(gate.gate_id);
|
|
98
|
+
pending.resolve(answer);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// A controller may answer synchronously while emitFrame is still on the
|
|
102
|
+
// stack. Keep the accepted answer so emitGate can still resolve after
|
|
103
|
+
// openGate returns and the caller starts awaiting.
|
|
104
|
+
this.#earlyAnswers.set(gate.gate_id, answer);
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
return {
|
|
108
|
+
run_id: this.opts.runId,
|
|
109
|
+
actor: controller.actor,
|
|
110
|
+
budget: controller.budget,
|
|
111
|
+
scopes: [...controller.scopes],
|
|
112
|
+
action_allowlist: [...controller.actionAllowlist],
|
|
113
|
+
accepted_at: new Date().toISOString(),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
preflightCommand(command: RpcCommand): void {
|
|
118
|
+
if (!this.#controller) return;
|
|
119
|
+
this.#controller.preflightToolCall(`${command.type} preflight`);
|
|
120
|
+
if (command.type === "bash") {
|
|
121
|
+
this.#controller.authorizeBash(command.command);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const scope = scopeForRpcCommand(command.type);
|
|
125
|
+
this.#controller.authorizeScope(scope);
|
|
126
|
+
this.#controller.authorizeAction(UnattendedRunController.actionClassForScope(scope));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
reconcileUsage(phase = "unattended usage reconciliation"): void {
|
|
130
|
+
const controller = this.#controller;
|
|
131
|
+
if (!controller) return;
|
|
132
|
+
controller.checkWallTime(phase);
|
|
133
|
+
const snapshot = this.opts.getUsageSnapshot?.();
|
|
134
|
+
if (!snapshot) return;
|
|
135
|
+
const current = controller.usageSnapshot();
|
|
136
|
+
const tokens = snapshot.tokens;
|
|
137
|
+
const costUsd = snapshot.costUsd;
|
|
138
|
+
controller.reconcile(
|
|
139
|
+
{
|
|
140
|
+
tokens: tokens !== undefined ? Math.max(0, tokens - current.tokens) : undefined,
|
|
141
|
+
costUsd: costUsd !== undefined ? Math.max(0, costUsd - current.costUsd) : undefined,
|
|
142
|
+
},
|
|
143
|
+
phase,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
resolveGate(response: RpcWorkflowGateResponse): Promise<RpcWorkflowGateResolution> {
|
|
148
|
+
if (!this.#broker) {
|
|
149
|
+
return Promise.reject(new Error("workflow gates are not available until unattended mode is negotiated"));
|
|
150
|
+
}
|
|
151
|
+
return this.#broker.resolve(response);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
emitGate(input: OpenGateInput): Promise<unknown> {
|
|
155
|
+
if (!this.#broker) {
|
|
156
|
+
return Promise.reject(new Error("cannot emit a workflow gate before unattended mode is negotiated"));
|
|
157
|
+
}
|
|
158
|
+
const gate = this.#broker.openGate(input);
|
|
159
|
+
if (this.#earlyAnswers.has(gate.gate_id)) {
|
|
160
|
+
const answer = this.#earlyAnswers.get(gate.gate_id);
|
|
161
|
+
this.#earlyAnswers.delete(gate.gate_id);
|
|
162
|
+
return Promise.resolve(answer);
|
|
163
|
+
}
|
|
164
|
+
const { promise, resolve, reject } = Promise.withResolvers<unknown>();
|
|
165
|
+
this.#pending.set(gate.gate_id, { resolve, reject });
|
|
166
|
+
return promise;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async recover(): Promise<void> {
|
|
170
|
+
await this.#broker?.recover();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#rejectAllPending(error: Error): void {
|
|
174
|
+
this.#earlyAnswers.clear();
|
|
175
|
+
for (const [gateId, pending] of this.#pending) {
|
|
176
|
+
this.#pending.delete(gateId);
|
|
177
|
+
pending.reject(error);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|