@nifrajs/coding-agent 3.5.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -4
- package/SECURITY.md +2 -1
- package/dist/approvals.d.ts +14 -2
- package/dist/approvals.d.ts.map +1 -1
- package/dist/approvals.js +51 -43
- package/dist/approvals.js.map +1 -1
- package/dist/cli.d.ts +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +11 -2
- package/dist/cli.js.map +1 -1
- package/dist/handoffs.d.ts.map +1 -1
- package/dist/handoffs.js +12 -4
- package/dist/handoffs.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/native.d.ts +7 -0
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +28 -7
- package/dist/native.js.map +1 -1
- package/dist/orchestration/compile.d.ts +7 -0
- package/dist/orchestration/compile.d.ts.map +1 -1
- package/dist/orchestration/compile.js +16 -2
- package/dist/orchestration/compile.js.map +1 -1
- package/dist/orchestration/evidence-store.d.ts.map +1 -1
- package/dist/orchestration/evidence-store.js +9 -4
- package/dist/orchestration/evidence-store.js.map +1 -1
- package/dist/orchestration/host.d.ts +10 -0
- package/dist/orchestration/host.d.ts.map +1 -1
- package/dist/orchestration/host.js +9 -0
- package/dist/orchestration/host.js.map +1 -1
- package/dist/rpc.d.ts +1 -1
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +10 -9
- package/dist/rpc.js.map +1 -1
- package/dist/session-migration.d.ts.map +1 -1
- package/dist/session-migration.js +3 -2
- package/dist/session-migration.js.map +1 -1
- package/dist/sessions.d.ts +6 -0
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +17 -2
- package/dist/sessions.js.map +1 -1
- package/dist/subagents.d.ts +30 -0
- package/dist/subagents.d.ts.map +1 -1
- package/dist/subagents.js +118 -15
- package/dist/subagents.js.map +1 -1
- package/dist/verification.d.ts +5 -1
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +6 -2
- package/dist/verification.js.map +1 -1
- package/dist/workflows.d.ts.map +1 -1
- package/dist/workflows.js +12 -2
- package/dist/workflows.js.map +1 -1
- package/package.json +9 -6
- package/src/approvals.ts +62 -45
- package/src/cli.ts +11 -2
- package/src/handoffs.ts +20 -8
- package/src/index.ts +2 -0
- package/src/native.ts +41 -8
- package/src/orchestration/compile.ts +28 -2
- package/src/orchestration/evidence-store.ts +9 -3
- package/src/orchestration/host.ts +17 -0
- package/src/rpc.ts +14 -13
- package/src/session-migration.ts +3 -2
- package/src/sessions.ts +18 -2
- package/src/subagents.ts +147 -17
- package/src/verification.ts +6 -2
- package/src/workflows.ts +10 -2
package/src/approvals.ts
CHANGED
|
@@ -52,10 +52,16 @@ export interface ApprovalManagerOptions {
|
|
|
52
52
|
|
|
53
53
|
interface PendingApproval {
|
|
54
54
|
readonly request: ApprovalRequest
|
|
55
|
-
|
|
55
|
+
/** Settles with the decision; it exists before `onRequired` runs, so no decision can miss it. */
|
|
56
|
+
readonly decided: Promise<boolean>
|
|
57
|
+
readonly settle: (approved: boolean) => void
|
|
56
58
|
readonly timer: ReturnType<typeof setTimeout>
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
/** The agent protocol's token alphabet, so every protocol-valid id is accepted here too. */
|
|
62
|
+
const ID_TOKEN = /^[A-Za-z0-9._:/-]{1,128}$/
|
|
63
|
+
const MAX_ACTION_LENGTH = 512
|
|
64
|
+
|
|
59
65
|
/**
|
|
60
66
|
* Small approval broker shared by RPC, Workbench, and workflow extensions.
|
|
61
67
|
*
|
|
@@ -89,38 +95,37 @@ export class ApprovalManager {
|
|
|
89
95
|
|
|
90
96
|
/** Convert a streamed backend approval event into a resolvable pending request. */
|
|
91
97
|
async observe(event: AgentApprovalRequiredEvent): Promise<ApprovalRequest | undefined> {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
98
|
+
const existing = this.pendingApprovals.get(event.approvalId)
|
|
99
|
+
if (existing !== undefined) return existing.request
|
|
100
|
+
const pending = await this.create({
|
|
95
101
|
id: event.approvalId,
|
|
96
102
|
sessionId: event.sessionId,
|
|
97
103
|
turnId: event.turnId,
|
|
98
|
-
|
|
104
|
+
// The protocol bounds text more loosely; a long label must not fail the backend's turn.
|
|
105
|
+
action: event.action.slice(0, MAX_ACTION_LENGTH),
|
|
99
106
|
capability: event.capability,
|
|
100
107
|
...(event.reason === undefined ? {} : { reason: event.reason }),
|
|
101
108
|
})
|
|
109
|
+
return pending?.request
|
|
102
110
|
}
|
|
103
111
|
|
|
104
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* Publish a pending approval without waiting for its decision (useful for transports). Returns
|
|
114
|
+
* `undefined` when the broker is full or an approval with the same id is still pending.
|
|
115
|
+
*/
|
|
105
116
|
async offer(
|
|
106
117
|
input: Omit<ApprovalRequest, "createdAt" | "expiresAt">,
|
|
107
118
|
): Promise<ApprovalRequest | undefined> {
|
|
108
|
-
return this.create(input)
|
|
119
|
+
return (await this.create(input))?.request
|
|
109
120
|
}
|
|
110
121
|
|
|
111
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* Create a host-owned approval that can be awaited by a workflow or extension. Resolves `false`
|
|
124
|
+
* when the broker is full or an approval with the same id is still pending.
|
|
125
|
+
*/
|
|
112
126
|
async request(input: Omit<ApprovalRequest, "createdAt" | "expiresAt">): Promise<boolean> {
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
const pending = this.pendingApprovals.get(request.id)
|
|
116
|
-
if (pending === undefined) return false
|
|
117
|
-
return new Promise<boolean>((resolve) => {
|
|
118
|
-
// Replace the resolver only for this host-owned request; observed backend requests are
|
|
119
|
-
// intentionally notification-only and are resolved through `resolve`.
|
|
120
|
-
const current = this.pendingApprovals.get(request.id)
|
|
121
|
-
if (current === undefined) return resolve(false)
|
|
122
|
-
this.pendingApprovals.set(request.id, { ...current, resolve })
|
|
123
|
-
})
|
|
127
|
+
const pending = await this.create(input)
|
|
128
|
+
return pending === undefined ? false : pending.decided
|
|
124
129
|
}
|
|
125
130
|
|
|
126
131
|
/**
|
|
@@ -145,49 +150,68 @@ export class ApprovalManager {
|
|
|
145
150
|
code: bound.vector !== coordinate.vector ? "stale_vector" : "identity_mismatch",
|
|
146
151
|
}
|
|
147
152
|
if (!coordinateIsFresh(bound, now)) {
|
|
148
|
-
this.
|
|
153
|
+
this.settle(coordinate.requestId, false, "approval expired")
|
|
149
154
|
return { ok: false, code: "expired" }
|
|
150
155
|
}
|
|
151
156
|
if (nextApprovalState("pending", approved ? "approve" : "deny") === undefined)
|
|
152
157
|
return { ok: false, code: "illegal_transition" }
|
|
153
|
-
const decision = this.
|
|
158
|
+
const decision = this.settle(coordinate.requestId, approved)
|
|
154
159
|
if (decision === undefined) return { ok: false, code: "unknown_boundary" }
|
|
155
160
|
return { ok: true, decision }
|
|
156
161
|
}
|
|
157
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Settle a pending approval by id. An approval opened with a coordinate is approved only through
|
|
165
|
+
* {@link resolveMatched}; this untyped path can still deny it, and returns `undefined` for an
|
|
166
|
+
* approve.
|
|
167
|
+
*/
|
|
158
168
|
resolve(approvalId: string, approved: boolean, reason?: string): ApprovalDecision | undefined {
|
|
169
|
+
if (
|
|
170
|
+
approved === true &&
|
|
171
|
+
this.pendingApprovals.get(approvalId)?.request.coordinate !== undefined
|
|
172
|
+
)
|
|
173
|
+
return undefined
|
|
174
|
+
return this.settle(approvalId, approved, reason)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
close(): void {
|
|
178
|
+
for (const approvalId of this.pendingApprovals.keys())
|
|
179
|
+
this.settle(approvalId, false, "approval manager closed")
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private settle(
|
|
183
|
+
approvalId: string,
|
|
184
|
+
approved: boolean,
|
|
185
|
+
reason?: string,
|
|
186
|
+
): ApprovalDecision | undefined {
|
|
159
187
|
const pending = this.pendingApprovals.get(approvalId)
|
|
160
188
|
if (pending === undefined) return undefined
|
|
161
189
|
clearTimeout(pending.timer)
|
|
162
190
|
this.pendingApprovals.delete(approvalId)
|
|
163
|
-
pending.
|
|
191
|
+
pending.settle(approved === true)
|
|
164
192
|
const decision: ApprovalDecision = {
|
|
165
193
|
approvalId,
|
|
166
194
|
approved: approved === true,
|
|
167
195
|
...(reason === undefined ? {} : { reason: reason.slice(0, 512) }),
|
|
168
196
|
at: Date.now(),
|
|
169
197
|
}
|
|
170
|
-
|
|
198
|
+
// A failing broadcast must not become an unhandled rejection in the host.
|
|
199
|
+
Promise.resolve(this.options.onResolved?.(decision)).catch(() => {})
|
|
171
200
|
return decision
|
|
172
201
|
}
|
|
173
202
|
|
|
174
|
-
close(): void {
|
|
175
|
-
for (const approvalId of this.pendingApprovals.keys())
|
|
176
|
-
this.resolve(approvalId, false, "approval manager closed")
|
|
177
|
-
}
|
|
178
|
-
|
|
179
203
|
private async create(
|
|
180
204
|
input: Omit<ApprovalRequest, "createdAt" | "expiresAt">,
|
|
181
|
-
): Promise<
|
|
205
|
+
): Promise<PendingApproval | undefined> {
|
|
182
206
|
if (this.pendingApprovals.size >= this.options.maxPending) return undefined
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
if (
|
|
186
|
-
throw new TypeError("approvals: session id is invalid")
|
|
187
|
-
if (!input.action || input.action.length > 512)
|
|
207
|
+
if (!ID_TOKEN.test(input.id)) throw new TypeError("approvals: approval id is invalid")
|
|
208
|
+
if (!ID_TOKEN.test(input.sessionId)) throw new TypeError("approvals: session id is invalid")
|
|
209
|
+
if (!input.action || input.action.length > MAX_ACTION_LENGTH)
|
|
188
210
|
throw new TypeError("approvals: action is empty or too long")
|
|
189
211
|
if (!input.capability || input.capability.length > 128)
|
|
190
212
|
throw new TypeError("approvals: capability is empty or too long")
|
|
213
|
+
// Replacing a pending id would orphan the waiter of the request already holding it.
|
|
214
|
+
if (this.pendingApprovals.has(input.id)) return undefined
|
|
191
215
|
const createdAt = Date.now()
|
|
192
216
|
const request: ApprovalRequest = Object.freeze({
|
|
193
217
|
...input,
|
|
@@ -197,22 +221,15 @@ export class ApprovalManager {
|
|
|
197
221
|
createdAt,
|
|
198
222
|
expiresAt: createdAt + this.options.timeoutMs,
|
|
199
223
|
})
|
|
200
|
-
|
|
224
|
+
const { promise: decided, resolve: settle } = Promise.withResolvers<boolean>()
|
|
201
225
|
const timer = setTimeout(() => {
|
|
202
|
-
this.
|
|
226
|
+
this.settle(request.id, false, "approval timed out")
|
|
203
227
|
}, this.options.timeoutMs)
|
|
204
228
|
;(timer as unknown as { unref?: () => void }).unref?.()
|
|
205
|
-
const pending: PendingApproval = {
|
|
206
|
-
request,
|
|
207
|
-
resolve: (approved) => resolveApproval(approved),
|
|
208
|
-
timer,
|
|
209
|
-
}
|
|
229
|
+
const pending: PendingApproval = { request, decided, settle, timer }
|
|
210
230
|
this.pendingApprovals.set(request.id, pending)
|
|
211
231
|
await this.options.onRequired?.(request)
|
|
212
|
-
|
|
213
|
-
// from the promise below before returning to the workflow.
|
|
214
|
-
resolveApproval = (_approved: boolean): void => {}
|
|
215
|
-
return request
|
|
232
|
+
return pending
|
|
216
233
|
}
|
|
217
234
|
}
|
|
218
235
|
|
package/src/cli.ts
CHANGED
|
@@ -25,10 +25,13 @@ Usage:
|
|
|
25
25
|
nifra-agent [--backend pi|replay] [--cwd <dir>] [--once <prompt>]
|
|
26
26
|
[--json] [--no-session] [--session-dir <dir>] [--session-id <id>]
|
|
27
27
|
[--verify-after-turn check,assure] [--max-repair-attempts <n>]
|
|
28
|
-
[--pi <command>] [--replay <file>]
|
|
28
|
+
[--pi <command>] [--replay <file>] [--extensions]
|
|
29
29
|
nifra-agent --migrate-session <id> --migrate-from <dir> --migrate-to <dir> [--json]
|
|
30
30
|
nifra-agent --rpc [--cwd <dir>] [--host 127.0.0.1] [--port 0] [--expose-error-stacks]
|
|
31
31
|
|
|
32
|
+
--extensions loads and runs .nifra/extensions/** from --cwd. That is the project's own code: pass it
|
|
33
|
+
only for a repository you trust. Without it no extension is loaded.
|
|
34
|
+
|
|
32
35
|
Commands in interactive mode:
|
|
33
36
|
/reload reload Pi or Nifra extensions
|
|
34
37
|
/compact compact the bounded prompt window
|
|
@@ -64,6 +67,7 @@ export interface CliOptions {
|
|
|
64
67
|
readonly migrationTarget?: string
|
|
65
68
|
readonly verifyAfterTurn: readonly ("check" | "assure" | "test")[]
|
|
66
69
|
readonly maxRepairAttempts: number
|
|
70
|
+
readonly extensions: boolean
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
export function parseArgs(args: readonly string[]): CliOptions {
|
|
@@ -72,6 +76,7 @@ export function parseArgs(args: readonly string[]): CliOptions {
|
|
|
72
76
|
let once: string | undefined
|
|
73
77
|
let json = false
|
|
74
78
|
let noSession = false
|
|
79
|
+
let extensions = false
|
|
75
80
|
let piCommand = "pi"
|
|
76
81
|
let replayFile: string | undefined
|
|
77
82
|
let sessionDir: string | undefined
|
|
@@ -93,6 +98,7 @@ export function parseArgs(args: readonly string[]): CliOptions {
|
|
|
93
98
|
else if (arg === "--once" || arg === "--message") once = args[++index]
|
|
94
99
|
else if (arg === "--json") json = true
|
|
95
100
|
else if (arg === "--no-session") noSession = true
|
|
101
|
+
else if (arg === "--extensions") extensions = true
|
|
96
102
|
else if (arg === "--session-dir") sessionDir = resolve(args[++index] ?? "")
|
|
97
103
|
else if (arg === "--session-id") sessionId = args[++index]
|
|
98
104
|
else if (arg === "--migrate-session") migrateSession = args[++index]
|
|
@@ -142,6 +148,7 @@ export function parseArgs(args: readonly string[]): CliOptions {
|
|
|
142
148
|
...(once === undefined ? {} : { once }),
|
|
143
149
|
json,
|
|
144
150
|
noSession,
|
|
151
|
+
extensions,
|
|
145
152
|
piCommand,
|
|
146
153
|
...(replayFile === undefined ? {} : { replayFile }),
|
|
147
154
|
...(sessionDir === undefined ? {} : { sessionDir }),
|
|
@@ -215,7 +222,9 @@ async function main(): Promise<void> {
|
|
|
215
222
|
enableNifraTools: true,
|
|
216
223
|
...(options.sessionDir === undefined ? {} : { sessionDir: options.sessionDir }),
|
|
217
224
|
})
|
|
218
|
-
|
|
225
|
+
// A module's top-level code runs as soon as it is imported, before any capability check can refuse
|
|
226
|
+
// it, so a cloned repository's extensions load only when the user asks.
|
|
227
|
+
const extensionRoots = options.extensions ? await discoverExtensions(options.cwd) : []
|
|
219
228
|
const extensions = new ExtensionHost({
|
|
220
229
|
cwd: options.cwd,
|
|
221
230
|
roots: extensionRoots,
|
package/src/handoffs.ts
CHANGED
|
@@ -27,6 +27,8 @@ import type { ApprovalManager } from "./approvals.ts"
|
|
|
27
27
|
import { ChildVectorTracker } from "./orchestration/policy.ts"
|
|
28
28
|
|
|
29
29
|
const ID_TOKEN = /^[A-Za-z0-9._:-]{1,128}$/
|
|
30
|
+
/** The agent protocol's token alphabet, which the approval broker accepts for session ids. */
|
|
31
|
+
const SESSION_TOKEN = /^[A-Za-z0-9._:/-]{1,128}$/
|
|
30
32
|
|
|
31
33
|
/** Stable, content-free reasons the coordinator refuses a handoff request or decision. */
|
|
32
34
|
export type HandoffRejection =
|
|
@@ -136,7 +138,8 @@ export class HandoffCoordinator {
|
|
|
136
138
|
!input.capability ||
|
|
137
139
|
input.capability.length > 128 ||
|
|
138
140
|
!input.from ||
|
|
139
|
-
input.from.length > 128
|
|
141
|
+
input.from.length > 128 ||
|
|
142
|
+
(input.sessionId !== undefined && !SESSION_TOKEN.test(input.sessionId))
|
|
140
143
|
)
|
|
141
144
|
throw new HandoffError("invalid_handoff")
|
|
142
145
|
if (this.records.has(input.requestId)) throw new HandoffError("duplicate")
|
|
@@ -162,13 +165,18 @@ export class HandoffCoordinator {
|
|
|
162
165
|
}
|
|
163
166
|
this.records.set(input.requestId, record)
|
|
164
167
|
|
|
168
|
+
// The mirror carries the boundary's coordinate, so only a matched decision approves it. It is a
|
|
169
|
+
// notification: a broker that is full or fails to broadcast leaves the handoff itself intact.
|
|
165
170
|
if (record.requireApproval && this.approvals !== undefined)
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
171
|
+
this.approvals
|
|
172
|
+
.offer({
|
|
173
|
+
id: input.requestId,
|
|
174
|
+
sessionId: input.sessionId ?? input.runId,
|
|
175
|
+
action: "handoff",
|
|
176
|
+
capability: input.capability,
|
|
177
|
+
coordinate,
|
|
178
|
+
})
|
|
179
|
+
.catch(() => {})
|
|
172
180
|
return this.view(record)
|
|
173
181
|
}
|
|
174
182
|
|
|
@@ -264,7 +272,11 @@ export class HandoffCoordinator {
|
|
|
264
272
|
|
|
265
273
|
private settleApproval(record: HandoffRecord, op: string): void {
|
|
266
274
|
if (!record.requireApproval || this.approvals === undefined) return
|
|
267
|
-
this.approvals.
|
|
275
|
+
this.approvals.resolveMatched(
|
|
276
|
+
record.coordinate,
|
|
277
|
+
op === "accept" || op === "resolve",
|
|
278
|
+
this.now(),
|
|
279
|
+
)
|
|
268
280
|
}
|
|
269
281
|
|
|
270
282
|
private isTerminal(state: HandoffLifecycleState): boolean {
|
package/src/index.ts
CHANGED
package/src/native.ts
CHANGED
|
@@ -123,6 +123,11 @@ export interface NifraBackendOptions {
|
|
|
123
123
|
readonly approvalTimeoutMs?: number
|
|
124
124
|
readonly maxSteps?: number
|
|
125
125
|
readonly maxMessageChars?: number
|
|
126
|
+
/**
|
|
127
|
+
* Character budget for the conversation a session sends to the model (default 1 MiB). Past it,
|
|
128
|
+
* whole earlier turns are dropped, oldest first; the current turn is always sent in full.
|
|
129
|
+
*/
|
|
130
|
+
readonly maxHistoryChars?: number
|
|
126
131
|
readonly now?: () => number
|
|
127
132
|
}
|
|
128
133
|
|
|
@@ -156,6 +161,7 @@ interface NativeSession {
|
|
|
156
161
|
readonly id: string
|
|
157
162
|
readonly cwd: string
|
|
158
163
|
readonly messages: NativeMessage[]
|
|
164
|
+
historyChars: number
|
|
159
165
|
controller: AbortController
|
|
160
166
|
snapshot: AgentSessionSnapshot
|
|
161
167
|
active: AgentEventStream | undefined
|
|
@@ -179,7 +185,9 @@ export class NifraBackend implements AgentBackend {
|
|
|
179
185
|
name: "nifra",
|
|
180
186
|
capabilities: Object.freeze(["sessions", "extensions", "reload", "approvals", "streaming"]),
|
|
181
187
|
})
|
|
182
|
-
private readonly options: Required<
|
|
188
|
+
private readonly options: Required<
|
|
189
|
+
Pick<NifraBackendOptions, "maxSteps" | "maxMessageChars" | "maxHistoryChars">
|
|
190
|
+
> &
|
|
183
191
|
NifraBackendOptions
|
|
184
192
|
private readonly sessions = new Map<string, NativeSession>()
|
|
185
193
|
private readonly pendingApprovals = new Map<string, NativePendingApproval>()
|
|
@@ -189,6 +197,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
189
197
|
...options,
|
|
190
198
|
maxSteps: options.maxSteps ?? 32,
|
|
191
199
|
maxMessageChars: options.maxMessageChars ?? 64 * 1024,
|
|
200
|
+
maxHistoryChars: options.maxHistoryChars ?? 1024 * 1024,
|
|
192
201
|
tools: Object.freeze([...(options.tools ?? [])]),
|
|
193
202
|
})
|
|
194
203
|
if (
|
|
@@ -199,6 +208,8 @@ export class NifraBackend implements AgentBackend {
|
|
|
199
208
|
throw new RangeError("nifra backend: maxSteps must be between 1 and 512")
|
|
200
209
|
if (!Number.isSafeInteger(this.options.maxMessageChars) || this.options.maxMessageChars < 256)
|
|
201
210
|
throw new RangeError("nifra backend: maxMessageChars must be at least 256")
|
|
211
|
+
if (!Number.isSafeInteger(this.options.maxHistoryChars) || this.options.maxHistoryChars < 256)
|
|
212
|
+
throw new RangeError("nifra backend: maxHistoryChars must be at least 256")
|
|
202
213
|
if (
|
|
203
214
|
this.options.approvalTimeoutMs !== undefined &&
|
|
204
215
|
(!Number.isSafeInteger(this.options.approvalTimeoutMs) ||
|
|
@@ -243,6 +254,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
243
254
|
id,
|
|
244
255
|
cwd: input.cwd,
|
|
245
256
|
messages: [],
|
|
257
|
+
historyChars: 0,
|
|
246
258
|
controller: new AbortController(),
|
|
247
259
|
snapshot,
|
|
248
260
|
active: undefined,
|
|
@@ -376,14 +388,15 @@ export class NifraBackend implements AgentBackend {
|
|
|
376
388
|
}
|
|
377
389
|
this.update(session, "running", turnId)
|
|
378
390
|
this.emit(session, { type: "turn.started", turnId, prompt: message })
|
|
379
|
-
|
|
391
|
+
this.remember(session, { role: "user", text: message })
|
|
380
392
|
try {
|
|
381
393
|
for (let step = 0; step < this.options.maxSteps; step++) {
|
|
382
394
|
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
395
|
+
this.trimHistory(session)
|
|
383
396
|
const raw = this.options.model.complete({
|
|
384
397
|
sessionId: session.id,
|
|
385
398
|
cwd: session.cwd,
|
|
386
|
-
messages: Object.freeze(session.messages.
|
|
399
|
+
messages: Object.freeze(session.messages.slice()),
|
|
387
400
|
tools: Object.freeze(
|
|
388
401
|
this.options.tools!.map((tool) => ({
|
|
389
402
|
name: tool.name,
|
|
@@ -397,7 +410,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
397
410
|
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
398
411
|
if (response.type === "text") {
|
|
399
412
|
const text = boundedText(response.text, this.options.maxMessageChars)
|
|
400
|
-
|
|
413
|
+
this.remember(session, { role: "assistant", text })
|
|
401
414
|
this.emit(session, { type: "assistant.message", turnId, text })
|
|
402
415
|
this.finish(session, stream)
|
|
403
416
|
return
|
|
@@ -412,7 +425,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
412
425
|
ok: false,
|
|
413
426
|
error: agentError("UNKNOWN_TOOL", `unknown native tool: ${response.name}`),
|
|
414
427
|
})
|
|
415
|
-
|
|
428
|
+
this.remember(session, { role: "tool", name: response.name, text: "unknown tool" })
|
|
416
429
|
continue
|
|
417
430
|
}
|
|
418
431
|
const callId = crypto.randomUUID()
|
|
@@ -449,7 +462,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
449
462
|
ok: false,
|
|
450
463
|
error: agentError(code, boundedApprovalMessage(code, tool.name, resolution.reason)),
|
|
451
464
|
})
|
|
452
|
-
|
|
465
|
+
this.remember(session, { role: "tool", name: tool.name, text: code })
|
|
453
466
|
continue
|
|
454
467
|
}
|
|
455
468
|
}
|
|
@@ -457,7 +470,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
457
470
|
const output = await execute(response.input, { cwd: session.cwd, signal })
|
|
458
471
|
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
459
472
|
const text = boundedText(output)
|
|
460
|
-
|
|
473
|
+
this.remember(session, { role: "tool", name: tool.name, text })
|
|
461
474
|
this.emit(session, {
|
|
462
475
|
type: "tool.completed",
|
|
463
476
|
turnId,
|
|
@@ -472,7 +485,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
472
485
|
error instanceof Error ? error.message : error,
|
|
473
486
|
this.options.maxMessageChars,
|
|
474
487
|
)
|
|
475
|
-
|
|
488
|
+
this.remember(session, { role: "tool", name: tool.name, text: message })
|
|
476
489
|
this.emit(session, {
|
|
477
490
|
type: "tool.completed",
|
|
478
491
|
turnId,
|
|
@@ -781,6 +794,26 @@ export class NifraBackend implements AgentBackend {
|
|
|
781
794
|
private now(): number {
|
|
782
795
|
return this.options.now?.() ?? Date.now()
|
|
783
796
|
}
|
|
797
|
+
|
|
798
|
+
private remember(session: NativeSession, message: NativeMessage): void {
|
|
799
|
+
session.messages.push(Object.freeze(message))
|
|
800
|
+
session.historyChars += messageChars(message)
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
private trimHistory(session: NativeSession): void {
|
|
804
|
+
while (session.historyChars > this.options.maxHistoryChars) {
|
|
805
|
+
const nextTurn = session.messages.findIndex(
|
|
806
|
+
(item, index) => index > 0 && item.role === "user",
|
|
807
|
+
)
|
|
808
|
+
if (nextTurn === -1) return
|
|
809
|
+
for (const item of session.messages.splice(0, nextTurn))
|
|
810
|
+
session.historyChars -= messageChars(item)
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function messageChars(message: NativeMessage): number {
|
|
816
|
+
return message.text.length + (message.name?.length ?? 0)
|
|
784
817
|
}
|
|
785
818
|
|
|
786
819
|
function isAsyncIterable<T>(value: unknown): value is AsyncIterable<T> {
|
|
@@ -19,7 +19,11 @@ import type {
|
|
|
19
19
|
RunPlan,
|
|
20
20
|
} from "@nifrajs/agent-protocol"
|
|
21
21
|
import { parseRunPlan } from "@nifrajs/agent-protocol"
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
BoundedSubagentRunner,
|
|
24
|
+
type SubagentAbandonment,
|
|
25
|
+
type SubagentAbandonmentLedger,
|
|
26
|
+
} from "../subagents.ts"
|
|
23
27
|
import type { WorkflowContext, WorkflowStep } from "../workflows.ts"
|
|
24
28
|
import { type CatalogStep, type StepCatalog, type StepRunContext, stepVersion } from "./catalog.ts"
|
|
25
29
|
import { deriveNodeEffectKey } from "./effect-key.ts"
|
|
@@ -48,6 +52,8 @@ export interface OrchestrationLimits {
|
|
|
48
52
|
readonly maxChildren?: number
|
|
49
53
|
/** Capability authority ceiling. `undefined` grants all; otherwise a step's needs must be a subset. */
|
|
50
54
|
readonly allowedCapabilities?: readonly string[]
|
|
55
|
+
/** Refuse a subagent node while this many abandoned subagent executors still run. Default: no bound. */
|
|
56
|
+
readonly maxAbandonedSubagents?: number
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
const DEFAULT_MAX_NODES = 256
|
|
@@ -69,6 +75,10 @@ export interface CompileOptions {
|
|
|
69
75
|
onNodeEffect?(nodeId: string, key: NodeEffectKey): void
|
|
70
76
|
/** Called for each artifact ref a node produces through its port. */
|
|
71
77
|
collectArtifact?(nodeId: string, ref: ArtifactRef): void
|
|
78
|
+
/** Called when a subagent node's run returns while its executor, which ignored the abort, keeps running. */
|
|
79
|
+
onSubagentAbandoned?(nodeId: string, abandonment: SubagentAbandonment): void
|
|
80
|
+
/** Where abandoned subagent executors are counted for `maxAbandonedSubagents`. Default: one per compile. */
|
|
81
|
+
readonly subagentAbandonment?: SubagentAbandonmentLedger
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
/**
|
|
@@ -101,7 +111,11 @@ export function compileRunPlanLayers(
|
|
|
101
111
|
throw new CompileError(`plan has ${total} nodes over the ${maxNodes} ceiling`, "E_MAX_NODES")
|
|
102
112
|
|
|
103
113
|
const layers = layerTopLevel(plan.nodes)
|
|
104
|
-
|
|
114
|
+
const wired: CompileOptions =
|
|
115
|
+
options.subagentAbandonment === undefined
|
|
116
|
+
? { ...options, subagentAbandonment: { abandoned: 0 } }
|
|
117
|
+
: options
|
|
118
|
+
return layers.map((layer) => compileLayer(layer, wired, 1))
|
|
105
119
|
}
|
|
106
120
|
|
|
107
121
|
/** Recursive node count over the whole tree. */
|
|
@@ -365,6 +379,18 @@ async function runSubagent(
|
|
|
365
379
|
...(options.limits?.allowedCapabilities !== undefined
|
|
366
380
|
? { allowedCapabilities: options.limits.allowedCapabilities }
|
|
367
381
|
: {}),
|
|
382
|
+
...(options.limits?.maxAbandonedSubagents !== undefined
|
|
383
|
+
? { maxAbandoned: options.limits.maxAbandonedSubagents }
|
|
384
|
+
: {}),
|
|
385
|
+
...(options.subagentAbandonment !== undefined
|
|
386
|
+
? { abandonment: options.subagentAbandonment }
|
|
387
|
+
: {}),
|
|
388
|
+
...(options.onSubagentAbandoned !== undefined
|
|
389
|
+
? {
|
|
390
|
+
onAbandoned: (abandonment: SubagentAbandonment) =>
|
|
391
|
+
options.onSubagentAbandoned?.(node.id, abandonment),
|
|
392
|
+
}
|
|
393
|
+
: {}),
|
|
368
394
|
})
|
|
369
395
|
const spec = await step.spec!(context)
|
|
370
396
|
const result = await runner.run(spec)
|
|
@@ -51,8 +51,9 @@ async function sha256Bytes(text: string): Promise<Uint8Array> {
|
|
|
51
51
|
/**
|
|
52
52
|
* The identity of one record for the terminal digest: node outcome only. `seq`, `durationMs`, and
|
|
53
53
|
* `runId` are excluded so two runs of the same plan (or the same plan under a different parallel
|
|
54
|
-
* schedule) produce an identical digest - the deterministic-eval anchor.
|
|
55
|
-
* record
|
|
54
|
+
* schedule) produce an identical digest - the deterministic-eval anchor. A retried node emits the same
|
|
55
|
+
* record more than once, so the accumulator adds hashes (mod 2^256) rather than XORing them: repeats
|
|
56
|
+
* count instead of cancelling in pairs.
|
|
56
57
|
*/
|
|
57
58
|
function digestKey(record: RunEvidence): string {
|
|
58
59
|
return canonicalJson({
|
|
@@ -97,7 +98,12 @@ class EvidenceAccumulator {
|
|
|
97
98
|
if (record.artifacts !== undefined)
|
|
98
99
|
for (const ref of record.artifacts) this.artifactRefs.push(ref)
|
|
99
100
|
const hash = await sha256Bytes(digestKey(record))
|
|
100
|
-
|
|
101
|
+
let carry = 0
|
|
102
|
+
for (let i = 31; i >= 0; i--) {
|
|
103
|
+
const sum = (this.acc[i] as number) + (hash[i] as number) + carry
|
|
104
|
+
this.acc[i] = sum & 0xff
|
|
105
|
+
carry = sum >> 8
|
|
106
|
+
}
|
|
101
107
|
this.pushLive(record)
|
|
102
108
|
}
|
|
103
109
|
|
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
RunPlan,
|
|
23
23
|
} from "@nifrajs/agent-protocol"
|
|
24
24
|
import { parseRunPlan, RUN_PLAN_VERSION } from "@nifrajs/agent-protocol"
|
|
25
|
+
import type { SubagentAbandonment, SubagentAbandonmentLedger } from "../subagents.ts"
|
|
25
26
|
import type { WorkflowStep } from "../workflows.ts"
|
|
26
27
|
import { type WorkflowEvent, WorkflowRunner } from "../workflows.ts"
|
|
27
28
|
import { noopArtifactPort } from "./artifact-port.ts"
|
|
@@ -92,6 +93,11 @@ export interface OrchestrationHostOptions {
|
|
|
92
93
|
* approval requirement. A descriptor, plan, model, or extension cannot override it.
|
|
93
94
|
*/
|
|
94
95
|
readonly policy?: HostPolicy
|
|
96
|
+
/**
|
|
97
|
+
* Called when a subagent node's run returns while its executor, which ignored the abort, keeps
|
|
98
|
+
* running. `limits.maxAbandonedSubagents` bounds how many may run at once across all runs.
|
|
99
|
+
*/
|
|
100
|
+
onSubagentAbandoned?(runId: string, nodeId: string, abandonment: SubagentAbandonment): void
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
const DEFAULT_MAX_STEPS = 100_000
|
|
@@ -128,6 +134,8 @@ export class OrchestrationHost {
|
|
|
128
134
|
private readonly vectors = new ChildVectorTracker()
|
|
129
135
|
private readonly runs = new Map<string, RunRecord>()
|
|
130
136
|
private counter = 0
|
|
137
|
+
private readonly subagentAbandonment: SubagentAbandonmentLedger = { abandoned: 0 }
|
|
138
|
+
private readonly onSubagentAbandoned: OrchestrationHostOptions["onSubagentAbandoned"]
|
|
131
139
|
|
|
132
140
|
constructor(options: OrchestrationHostOptions) {
|
|
133
141
|
this.catalog = options.catalog
|
|
@@ -135,6 +143,12 @@ export class OrchestrationHost {
|
|
|
135
143
|
this.maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS
|
|
136
144
|
this.maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH
|
|
137
145
|
this.policy = options.policy
|
|
146
|
+
this.onSubagentAbandoned = options.onSubagentAbandoned
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Subagent executors abandoned by any run of this host that are still running. */
|
|
150
|
+
get abandonedSubagents(): number {
|
|
151
|
+
return this.subagentAbandonment.abandoned
|
|
138
152
|
}
|
|
139
153
|
|
|
140
154
|
/**
|
|
@@ -186,6 +200,9 @@ export class OrchestrationHost {
|
|
|
186
200
|
planDigest,
|
|
187
201
|
artifactPort,
|
|
188
202
|
...(this.limits !== undefined ? { limits: this.limits } : {}),
|
|
203
|
+
subagentAbandonment: this.subagentAbandonment,
|
|
204
|
+
onSubagentAbandoned: (nodeId, abandonment) =>
|
|
205
|
+
this.onSubagentAbandoned?.(runId, nodeId, abandonment),
|
|
189
206
|
onNodeEffect: (nodeId, key) => effectKeys.set(nodeId, key),
|
|
190
207
|
collectArtifact: (nodeId, ref) => {
|
|
191
208
|
const list = artifacts.get(nodeId)
|
package/src/rpc.ts
CHANGED
|
@@ -162,17 +162,19 @@ export class CodingAgentRpcServer {
|
|
|
162
162
|
const url = new URL(request.url)
|
|
163
163
|
if (url.pathname === "/health" && request.method === "GET")
|
|
164
164
|
return this.response({ ok: true, protocol: 1 }, 200, cors)
|
|
165
|
-
const backoffMs = this.authBackoffMs()
|
|
166
|
-
if (backoffMs > 0) {
|
|
167
|
-
const throttledCors = new Headers(cors)
|
|
168
|
-
throttledCors.set("retry-after", String(Math.ceil(backoffMs / 1000)))
|
|
169
|
-
return this.response(
|
|
170
|
-
{ error: { code: "auth_throttled", message: "too many failed authorization attempts" } },
|
|
171
|
-
429,
|
|
172
|
-
throttledCors,
|
|
173
|
-
)
|
|
174
|
-
}
|
|
175
165
|
if (!authorized(request, this.token)) {
|
|
166
|
+
// Only failed attempts are throttled. A caller holding the token is served during a backoff,
|
|
167
|
+
// or anyone who can reach the port could keep the real client locked out with wrong guesses.
|
|
168
|
+
const backoffMs = this.authBackoffMs()
|
|
169
|
+
if (backoffMs > 0) {
|
|
170
|
+
const throttledCors = new Headers(cors)
|
|
171
|
+
throttledCors.set("retry-after", String(Math.ceil(backoffMs / 1000)))
|
|
172
|
+
return this.response(
|
|
173
|
+
{ error: { code: "auth_throttled", message: "too many failed authorization attempts" } },
|
|
174
|
+
429,
|
|
175
|
+
throttledCors,
|
|
176
|
+
)
|
|
177
|
+
}
|
|
176
178
|
this.noteAuthFailure()
|
|
177
179
|
return this.response(
|
|
178
180
|
{ error: { code: "unauthorized", message: "agent RPC authorization required" } },
|
|
@@ -180,7 +182,6 @@ export class CodingAgentRpcServer {
|
|
|
180
182
|
cors,
|
|
181
183
|
)
|
|
182
184
|
}
|
|
183
|
-
this.noteAuthSuccess()
|
|
184
185
|
if (url.pathname !== "/rpc" || request.method !== "POST")
|
|
185
186
|
return this.response(
|
|
186
187
|
{ error: { code: "not_found", message: "unknown agent RPC endpoint" } },
|
|
@@ -635,7 +636,7 @@ export class CodingAgentRpcServer {
|
|
|
635
636
|
const now = Date.now()
|
|
636
637
|
if (this.authFailures === 0) return 0
|
|
637
638
|
if (now - this.authLastFailureAt > AUTH_FAILURE_WINDOW_MS) {
|
|
638
|
-
this.
|
|
639
|
+
this.resetAuthFailures()
|
|
639
640
|
return 0
|
|
640
641
|
}
|
|
641
642
|
return Math.max(0, this.authBackoffUntil - now)
|
|
@@ -653,7 +654,7 @@ export class CodingAgentRpcServer {
|
|
|
653
654
|
}
|
|
654
655
|
}
|
|
655
656
|
|
|
656
|
-
private
|
|
657
|
+
private resetAuthFailures(): void {
|
|
657
658
|
this.authFailures = 0
|
|
658
659
|
this.authLastFailureAt = 0
|
|
659
660
|
this.authBackoffUntil = 0
|