@nifrajs/coding-agent 4.0.2 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +22 -2
- package/dist/approvals.d.ts +30 -2
- package/dist/approvals.d.ts.map +1 -1
- package/dist/approvals.js +43 -12
- package/dist/approvals.js.map +1 -1
- package/dist/cli.d.ts +13 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +120 -100
- package/dist/cli.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +9 -21
- package/dist/context.js.map +1 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +8 -21
- package/dist/diff.js.map +1 -1
- package/dist/extensions.js +4 -2
- package/dist/extensions.js.map +1 -1
- package/dist/handoffs.d.ts +5 -0
- package/dist/handoffs.d.ts.map +1 -1
- package/dist/handoffs.js +27 -0
- package/dist/handoffs.js.map +1 -1
- package/dist/host.d.ts +15 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +152 -37
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/isolated.js +3 -2
- package/dist/isolated.js.map +1 -1
- package/dist/native.d.ts +29 -2
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +86 -26
- package/dist/native.js.map +1 -1
- package/dist/orchestration/dispatch.d.ts +5 -1
- package/dist/orchestration/dispatch.d.ts.map +1 -1
- package/dist/orchestration/dispatch.js +1 -1
- package/dist/orchestration/dispatch.js.map +1 -1
- package/dist/orchestration/evidence-store.d.ts.map +1 -1
- package/dist/orchestration/evidence-store.js +5 -6
- package/dist/orchestration/evidence-store.js.map +1 -1
- package/dist/orchestration/host.d.ts +14 -0
- package/dist/orchestration/host.d.ts.map +1 -1
- package/dist/orchestration/host.js +26 -0
- package/dist/orchestration/host.js.map +1 -1
- package/dist/orchestration/index.d.ts +1 -1
- package/dist/orchestration/index.d.ts.map +1 -1
- package/dist/orchestration/index.js.map +1 -1
- package/dist/orchestration/recovery.d.ts +5 -1
- package/dist/orchestration/recovery.d.ts.map +1 -1
- package/dist/orchestration/recovery.js +5 -3
- package/dist/orchestration/recovery.js.map +1 -1
- package/dist/process.d.ts +22 -1
- package/dist/process.d.ts.map +1 -1
- package/dist/process.js +75 -1
- package/dist/process.js.map +1 -1
- package/dist/rpc.d.ts +29 -0
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +205 -46
- package/dist/rpc.js.map +1 -1
- package/dist/session-migration.d.ts.map +1 -1
- package/dist/session-migration.js +30 -6
- package/dist/session-migration.js.map +1 -1
- package/dist/sessions.d.ts +13 -2
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +104 -24
- package/dist/sessions.js.map +1 -1
- package/dist/subagents.d.ts.map +1 -1
- package/dist/subagents.js +13 -2
- package/dist/subagents.js.map +1 -1
- package/dist/turn-replay.d.ts +40 -0
- package/dist/turn-replay.d.ts.map +1 -0
- package/dist/turn-replay.js +136 -0
- package/dist/turn-replay.js.map +1 -0
- package/dist/ui.js +84 -30
- package/dist/ui.js.map +1 -1
- package/dist/verification.d.ts +7 -0
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +66 -63
- package/dist/verification.js.map +1 -1
- package/dist/workflows.d.ts +5 -0
- package/dist/workflows.d.ts.map +1 -1
- package/dist/workflows.js +2 -1
- package/dist/workflows.js.map +1 -1
- package/package.json +6 -6
- package/src/approvals.ts +70 -16
- package/src/cli.ts +125 -93
- package/src/context.ts +12 -20
- package/src/diff.ts +11 -23
- package/src/extensions.ts +6 -2
- package/src/handoffs.ts +27 -0
- package/src/host.ts +165 -34
- package/src/index.ts +3 -0
- package/src/isolated.ts +3 -2
- package/src/native.ts +137 -34
- package/src/orchestration/dispatch.ts +6 -2
- package/src/orchestration/evidence-store.ts +5 -6
- package/src/orchestration/host.ts +44 -0
- package/src/orchestration/index.ts +1 -0
- package/src/orchestration/recovery.ts +14 -8
- package/src/process.ts +89 -0
- package/src/rpc.ts +268 -49
- package/src/session-migration.ts +29 -5
- package/src/sessions.ts +115 -29
- package/src/subagents.ts +11 -2
- package/src/turn-replay.ts +151 -0
- package/src/ui.ts +90 -32
- package/src/verification.ts +74 -60
- package/src/workflows.ts +7 -1
package/src/host.ts
CHANGED
|
@@ -4,11 +4,14 @@ import {
|
|
|
4
4
|
type AgentBackend,
|
|
5
5
|
type AgentEvent,
|
|
6
6
|
type AgentEventPayload,
|
|
7
|
+
type AgentExtensionEvent,
|
|
7
8
|
type AgentSessionSnapshot,
|
|
8
9
|
agentError,
|
|
10
|
+
agentEventProblem,
|
|
9
11
|
type CreateSessionInput,
|
|
10
12
|
type ForkSessionResult,
|
|
11
13
|
isAgentEvent,
|
|
14
|
+
isAgentExtensionEventType,
|
|
12
15
|
type ReloadResult,
|
|
13
16
|
} from "@nifrajs/agent-protocol"
|
|
14
17
|
import {
|
|
@@ -50,19 +53,31 @@ export interface CodingAgentHostOptions {
|
|
|
50
53
|
>
|
|
51
54
|
/** Retain bounded exception stacks for trusted local diagnostics. */
|
|
52
55
|
readonly exposeErrorStacks?: boolean
|
|
56
|
+
/**
|
|
57
|
+
* Checks for a backend's extension events (`x-<vendor>.<name>`), by type. An event whose check
|
|
58
|
+
* returns false or throws fails the turn like a malformed protocol event; a type with no check
|
|
59
|
+
* passes through as long as it stays inside the protocol's bounds.
|
|
60
|
+
*/
|
|
61
|
+
readonly extensionEvents?: Readonly<Record<string, (event: AgentExtensionEvent) => boolean>>
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
/** Small lifecycle host shared by the CLI, RPC server, and future Workbench. */
|
|
56
65
|
export class CodingAgentHost {
|
|
57
66
|
readonly backend: AgentBackend
|
|
58
67
|
private session: AgentSessionSnapshot | undefined
|
|
68
|
+
private starting: Promise<AgentSessionSnapshot> | undefined
|
|
69
|
+
// Bumped by every start and stop: a start that finds it changed was superseded by a stop.
|
|
70
|
+
private lifecycle = 0
|
|
59
71
|
private readonly sessionStore: SessionStore | undefined
|
|
60
72
|
private readonly context: ContextWindow
|
|
61
73
|
private readonly extensions: ExtensionHost | undefined
|
|
62
74
|
readonly approvals: ApprovalManager
|
|
75
|
+
/** Approvals {@link resolveApproval} is settling; it forwards those itself, once the broker accepts. */
|
|
76
|
+
private readonly resolving = new Set<string>()
|
|
63
77
|
private readonly verifyAfterTurn: readonly ("check" | "assure" | "test")[]
|
|
64
78
|
private readonly maxRepairAttempts: number
|
|
65
79
|
private readonly exposeErrorStacks: boolean
|
|
80
|
+
private readonly extensionChecks: ReadonlyMap<string, (event: AgentExtensionEvent) => boolean>
|
|
66
81
|
private readonly verification:
|
|
67
82
|
| Pick<VerificationOptions, "command" | "commandArgs" | "timeoutMs" | "maxOutputBytes" | "env">
|
|
68
83
|
| undefined
|
|
@@ -72,7 +87,28 @@ export class CodingAgentHost {
|
|
|
72
87
|
this.sessionStore = options.sessionStore
|
|
73
88
|
this.context = new ContextWindow(options.contextWindow)
|
|
74
89
|
this.extensions = options.extensions
|
|
75
|
-
|
|
90
|
+
const onResolved = options.approvals?.onResolved
|
|
91
|
+
this.approvals = new ApprovalManager({
|
|
92
|
+
...options.approvals,
|
|
93
|
+
// A denial the broker reaches on its own (timeout, expiry, close) goes to the backend too, or a
|
|
94
|
+
// backend holding the approval open longer than the host could still act on it.
|
|
95
|
+
onResolved: async (decision) => {
|
|
96
|
+
const sessionId = this.session?.id
|
|
97
|
+
if (!decision.approved && !this.resolving.has(decision.approvalId) && sessionId) {
|
|
98
|
+
try {
|
|
99
|
+
await this.backend.resolveApproval?.(
|
|
100
|
+
sessionId,
|
|
101
|
+
decision.approvalId,
|
|
102
|
+
false,
|
|
103
|
+
decision.reason,
|
|
104
|
+
)
|
|
105
|
+
} catch {
|
|
106
|
+
// The broker's denial stands whether or not the backend still knew the approval.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
await onResolved?.(decision)
|
|
110
|
+
},
|
|
111
|
+
})
|
|
76
112
|
this.verifyAfterTurn = Object.freeze([...(options.verifyAfterTurn ?? [])])
|
|
77
113
|
this.maxRepairAttempts = options.maxRepairAttempts ?? 2
|
|
78
114
|
if (
|
|
@@ -83,11 +119,39 @@ export class CodingAgentHost {
|
|
|
83
119
|
throw new RangeError("coding agent: maxRepairAttempts must be between 0 and 8")
|
|
84
120
|
this.exposeErrorStacks = options.exposeErrorStacks === true
|
|
85
121
|
this.verification = options.verification
|
|
122
|
+
const checks = new Map<string, (event: AgentExtensionEvent) => boolean>()
|
|
123
|
+
for (const [type, check] of Object.entries(options.extensionEvents ?? {})) {
|
|
124
|
+
if (!isAgentExtensionEventType(type))
|
|
125
|
+
throw new TypeError(`coding agent: extension event type must be x-<vendor>.<name>: ${type}`)
|
|
126
|
+
if (typeof check !== "function")
|
|
127
|
+
throw new TypeError(`coding agent: the check for ${type} must be a function`)
|
|
128
|
+
checks.set(type, check)
|
|
129
|
+
}
|
|
130
|
+
this.extensionChecks = checks
|
|
86
131
|
}
|
|
87
132
|
|
|
88
133
|
async start(input: CreateSessionInput): Promise<AgentSessionSnapshot> {
|
|
89
|
-
if (this.session !== undefined
|
|
134
|
+
if (this.session !== undefined || this.starting !== undefined)
|
|
135
|
+
throw new Error("coding agent: a session is already active")
|
|
136
|
+
const starting = this.startSession(input, ++this.lifecycle)
|
|
137
|
+
this.starting = starting
|
|
138
|
+
try {
|
|
139
|
+
return await starting
|
|
140
|
+
} finally {
|
|
141
|
+
if (this.starting === starting) this.starting = undefined
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private async startSession(
|
|
146
|
+
input: CreateSessionInput,
|
|
147
|
+
generation: number,
|
|
148
|
+
): Promise<AgentSessionSnapshot> {
|
|
90
149
|
const created = await this.backend.createSession(input)
|
|
150
|
+
if (generation !== this.lifecycle) {
|
|
151
|
+
await this.backend.cancel(created.id, "host stopped").catch(() => {})
|
|
152
|
+
await this.backend.close(created.id).catch(() => {})
|
|
153
|
+
throw new Error("coding agent: the host stopped while the session was starting")
|
|
154
|
+
}
|
|
91
155
|
this.session = created
|
|
92
156
|
try {
|
|
93
157
|
let history: readonly import("./sessions.ts").SessionLogEntry[] = []
|
|
@@ -100,6 +164,8 @@ export class CodingAgentHost {
|
|
|
100
164
|
this.nextSeq = nextSequence(created, history)
|
|
101
165
|
await this.extensions?.reload()
|
|
102
166
|
await this.sessionStore?.append(created.id, "session.started", created)
|
|
167
|
+
if (generation !== this.lifecycle)
|
|
168
|
+
throw new Error("coding agent: the host stopped while the session was starting")
|
|
103
169
|
return created
|
|
104
170
|
} catch (error) {
|
|
105
171
|
// Session creation may allocate a process or other external resource. If history, extension
|
|
@@ -125,31 +191,40 @@ export class CodingAgentHost {
|
|
|
125
191
|
const sessionId = this.session.id
|
|
126
192
|
let nextMessage = message
|
|
127
193
|
let repairAttempts = 0
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
194
|
+
// A reader that leaves early (an RPC client disconnecting) never records the backend's
|
|
195
|
+
// `approval.resolved`, so the turn's approvals are denied here instead of waiting on a timer.
|
|
196
|
+
const raised = new Set<string>()
|
|
197
|
+
try {
|
|
198
|
+
for (;;) {
|
|
199
|
+
const stream = this.backend.send({
|
|
200
|
+
sessionId,
|
|
201
|
+
message: nextMessage,
|
|
202
|
+
...(signal === undefined ? {} : { signal }),
|
|
203
|
+
})
|
|
204
|
+
for await (const rawEvent of stream) {
|
|
205
|
+
const event = this.acceptBackendEvent(rawEvent)
|
|
206
|
+
await this.recordEvent(event)
|
|
207
|
+
if (event.type === "approval.required") raised.add(event.approvalId)
|
|
208
|
+
yield event
|
|
209
|
+
if (event.type === "session.stopped") return
|
|
210
|
+
if (event.type === "session.failed") {
|
|
211
|
+
if (event.error.code === "BACKEND_PROTOCOL")
|
|
212
|
+
await this.backend.cancel(sessionId, "backend protocol error").catch(() => {})
|
|
213
|
+
return
|
|
214
|
+
}
|
|
143
215
|
}
|
|
144
|
-
|
|
145
|
-
if (signal?.aborted === true) return
|
|
216
|
+
if (signal?.aborted === true) return
|
|
146
217
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
218
|
+
const repairTasks = await this.verifyTurn()
|
|
219
|
+
for (const event of repairTasks.events) yield event
|
|
220
|
+
if (repairTasks.tasks.length === 0 || repairAttempts >= this.maxRepairAttempts) return
|
|
150
221
|
|
|
151
|
-
|
|
152
|
-
|
|
222
|
+
repairAttempts++
|
|
223
|
+
nextMessage = createRepairPrompt(repairTasks.tasks, repairAttempts, this.maxRepairAttempts)
|
|
224
|
+
}
|
|
225
|
+
} finally {
|
|
226
|
+
for (const approvalId of raised)
|
|
227
|
+
this.approvals.resolve(approvalId, false, "approval turn ended")
|
|
153
228
|
}
|
|
154
229
|
}
|
|
155
230
|
|
|
@@ -157,13 +232,11 @@ export class CodingAgentHost {
|
|
|
157
232
|
|
|
158
233
|
private acceptBackendEvent(rawEvent: unknown): AgentEvent {
|
|
159
234
|
const session = this.requireSession()
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
})
|
|
166
|
-
}
|
|
235
|
+
// agentEventProblem re-walks the event, so it runs only once the event is known to be bad.
|
|
236
|
+
if (!isAgentEvent(rawEvent))
|
|
237
|
+
return this.protocolFailure(agentEventProblem(rawEvent) ?? "event is invalid")
|
|
238
|
+
const problem = this.acceptedEventProblem(rawEvent, session.id)
|
|
239
|
+
if (problem !== undefined) return this.protocolFailure(problem)
|
|
167
240
|
const event = Object.freeze({
|
|
168
241
|
...rawEvent,
|
|
169
242
|
sessionId: session.id,
|
|
@@ -210,6 +283,28 @@ export class CodingAgentHost {
|
|
|
210
283
|
return event
|
|
211
284
|
}
|
|
212
285
|
|
|
286
|
+
private protocolFailure(problem: string): AgentEvent {
|
|
287
|
+
return this.createEvent({
|
|
288
|
+
type: "session.failed",
|
|
289
|
+
error: agentError("BACKEND_PROTOCOL", `backend emitted an invalid agent event: ${problem}`),
|
|
290
|
+
recoverable: false,
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private acceptedEventProblem(event: AgentEvent, sessionId: string): string | undefined {
|
|
295
|
+
if (event.sessionId !== sessionId) return "sessionId is not this session's"
|
|
296
|
+
if (!isExtensionEvent(event)) return undefined
|
|
297
|
+
const check = this.extensionChecks.get(event.type)
|
|
298
|
+
if (check === undefined) return undefined
|
|
299
|
+
let accepted = false
|
|
300
|
+
try {
|
|
301
|
+
accepted = check(event) === true
|
|
302
|
+
} catch {
|
|
303
|
+
accepted = false
|
|
304
|
+
}
|
|
305
|
+
return accepted ? undefined : `${event.type} failed its registered check`
|
|
306
|
+
}
|
|
307
|
+
|
|
213
308
|
private async verifyTurn(): Promise<{
|
|
214
309
|
readonly events: readonly AgentEvent[]
|
|
215
310
|
readonly tasks: readonly VerificationRepairTask[]
|
|
@@ -376,6 +471,9 @@ export class CodingAgentHost {
|
|
|
376
471
|
}
|
|
377
472
|
|
|
378
473
|
async stop(reason = "stopped"): Promise<void> {
|
|
474
|
+
// A start still in flight is superseded: wait for it to roll its session back.
|
|
475
|
+
this.lifecycle++
|
|
476
|
+
await this.starting?.catch(() => {})
|
|
379
477
|
if (this.session === undefined) return
|
|
380
478
|
const id = this.session.id
|
|
381
479
|
this.session = undefined
|
|
@@ -420,10 +518,19 @@ export class CodingAgentHost {
|
|
|
420
518
|
approved: boolean,
|
|
421
519
|
reason?: string,
|
|
422
520
|
): Promise<ApprovalDecision | undefined> {
|
|
521
|
+
// The broker decides first: an approval it no longer holds (unknown, expired, already settled,
|
|
522
|
+
// or bound to a coordinate) never reaches the backend.
|
|
523
|
+
this.resolving.add(approvalId)
|
|
524
|
+
let decision: ApprovalDecision | undefined
|
|
525
|
+
try {
|
|
526
|
+
decision = this.approvals.resolve(approvalId, approved, reason)
|
|
527
|
+
} finally {
|
|
528
|
+
this.resolving.delete(approvalId)
|
|
529
|
+
}
|
|
530
|
+
if (decision === undefined) return undefined
|
|
423
531
|
if (this.session !== undefined && this.backend.resolveApproval !== undefined)
|
|
424
|
-
await this.backend.resolveApproval(this.session.id, approvalId, approved, reason)
|
|
425
|
-
|
|
426
|
-
if (decision !== undefined && this.session !== undefined)
|
|
532
|
+
await this.backend.resolveApproval(this.session.id, approvalId, decision.approved, reason)
|
|
533
|
+
if (this.session !== undefined)
|
|
427
534
|
await this.sessionStore?.append(this.session.id, "approval.resolved", decision, {
|
|
428
535
|
pinned: true,
|
|
429
536
|
})
|
|
@@ -517,8 +624,14 @@ function boundedHostToken(value: unknown): value is string {
|
|
|
517
624
|
return typeof value === "string" && /^[A-Za-z0-9._:/-]{1,128}$/.test(value)
|
|
518
625
|
}
|
|
519
626
|
|
|
627
|
+
function isExtensionEvent(event: AgentEvent): event is AgentExtensionEvent {
|
|
628
|
+
return isAgentExtensionEventType(event.type)
|
|
629
|
+
}
|
|
630
|
+
|
|
520
631
|
/** Default session evidence is content-free; live protocol consumers still receive the full event. */
|
|
521
632
|
function eventEvidence(event: AgentEvent): unknown {
|
|
633
|
+
// An extension event's data is the backend's own content; evidence keeps only where it belongs.
|
|
634
|
+
if (isExtensionEvent(event)) return event.turnId === undefined ? {} : { turnId: event.turnId }
|
|
522
635
|
switch (event.type) {
|
|
523
636
|
case "session.started":
|
|
524
637
|
case "session.updated":
|
|
@@ -533,7 +646,25 @@ function eventEvidence(event: AgentEvent): unknown {
|
|
|
533
646
|
return { turnId: event.turnId }
|
|
534
647
|
case "assistant.delta":
|
|
535
648
|
case "assistant.message":
|
|
649
|
+
case "reasoning.delta":
|
|
536
650
|
return { turnId: event.turnId }
|
|
651
|
+
case "plan.updated":
|
|
652
|
+
return {
|
|
653
|
+
turnId: event.turnId,
|
|
654
|
+
entries: event.entries.length,
|
|
655
|
+
completed: event.entries.filter((entry) => entry.status === "completed").length,
|
|
656
|
+
}
|
|
657
|
+
case "context.usage": {
|
|
658
|
+
const {
|
|
659
|
+
version: _version,
|
|
660
|
+
sessionId: _sessionId,
|
|
661
|
+
seq: _seq,
|
|
662
|
+
at: _at,
|
|
663
|
+
type: _type,
|
|
664
|
+
...counts
|
|
665
|
+
} = event
|
|
666
|
+
return counts
|
|
667
|
+
}
|
|
537
668
|
case "tool.started":
|
|
538
669
|
return { turnId: event.turnId, callId: event.callId, name: event.name }
|
|
539
670
|
case "tool.delta":
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
export { PiBackend, type PiBackendOptions } from "@nifrajs/pi"
|
|
2
2
|
export {
|
|
3
|
+
APPROVAL_NEVER_EXPIRES_AT,
|
|
3
4
|
type ApprovalDecision,
|
|
4
5
|
ApprovalManager,
|
|
5
6
|
type ApprovalManagerOptions,
|
|
6
7
|
type ApprovalMatchRejection,
|
|
7
8
|
type ApprovalMatchResult,
|
|
8
9
|
type ApprovalRequest,
|
|
10
|
+
type ApprovalTimeout,
|
|
9
11
|
approvalRequestFromEvent,
|
|
10
12
|
approvalResolvedEvent,
|
|
11
13
|
} from "./approvals.ts"
|
|
@@ -153,6 +155,7 @@ export {
|
|
|
153
155
|
export {
|
|
154
156
|
createVerificationRepairTask,
|
|
155
157
|
isSafeReviewDiff,
|
|
158
|
+
isVerificationAvailable,
|
|
156
159
|
type ReviewExecutionErrorCode,
|
|
157
160
|
type ReviewExecutionResult,
|
|
158
161
|
type ReviewOptions,
|
package/src/isolated.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs"
|
|
2
|
-
import { isAbsolute, relative, resolve } from "node:path"
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from "node:path"
|
|
3
3
|
import { fileURLToPath } from "node:url"
|
|
4
4
|
import { readBoundedText } from "./process.ts"
|
|
5
5
|
|
|
@@ -321,7 +321,8 @@ export class IsolatedExtensionWorker {
|
|
|
321
321
|
|
|
322
322
|
function isWithin(root: string, candidate: string): boolean {
|
|
323
323
|
const path = relative(resolve(root), resolve(candidate))
|
|
324
|
-
|
|
324
|
+
// On Windows a path on another drive has no relative form, so `relative` returns it absolute.
|
|
325
|
+
return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path))
|
|
325
326
|
}
|
|
326
327
|
|
|
327
328
|
function isValidPort(value: unknown): value is number {
|
package/src/native.ts
CHANGED
|
@@ -5,18 +5,23 @@ import {
|
|
|
5
5
|
structuredOutputParser,
|
|
6
6
|
} from "@nifrajs/agent"
|
|
7
7
|
import {
|
|
8
|
+
AGENT_MAX_PAYLOAD_STRING,
|
|
8
9
|
type AgentBackend,
|
|
9
10
|
type AgentBackendInfo,
|
|
10
11
|
type AgentEvent,
|
|
11
12
|
type AgentEventStream,
|
|
12
13
|
type AgentSessionSnapshot,
|
|
13
14
|
agentError,
|
|
15
|
+
boundAgentText,
|
|
16
|
+
boundAgentValue,
|
|
14
17
|
type CreateSessionInput,
|
|
15
18
|
createAgentEventStream,
|
|
16
19
|
isAgentEvent,
|
|
17
20
|
type ReloadResult,
|
|
18
21
|
type SendMessageInput,
|
|
22
|
+
splitAgentText,
|
|
19
23
|
} from "@nifrajs/agent-protocol"
|
|
24
|
+
import { type ApprovalTimeout, isApprovalTimeout } from "./approvals.ts"
|
|
20
25
|
|
|
21
26
|
export interface NativeMessage {
|
|
22
27
|
readonly role: "user" | "assistant" | "tool"
|
|
@@ -49,6 +54,18 @@ export type NativeModelResponse =
|
|
|
49
54
|
|
|
50
55
|
export type NativeModelChunk =
|
|
51
56
|
| { readonly type: "text_delta"; readonly text: string }
|
|
57
|
+
| { readonly type: "reasoning_delta"; readonly text: string; readonly itemId?: string }
|
|
58
|
+
/** Token counts for the request; becomes a `context.usage` event. */
|
|
59
|
+
| {
|
|
60
|
+
readonly type: "usage"
|
|
61
|
+
readonly usedTokens: number
|
|
62
|
+
readonly windowTokens?: number
|
|
63
|
+
readonly inputTokens?: number
|
|
64
|
+
readonly outputTokens?: number
|
|
65
|
+
readonly cachedInputTokens?: number
|
|
66
|
+
readonly reasoningTokens?: number
|
|
67
|
+
readonly estimated?: boolean
|
|
68
|
+
}
|
|
52
69
|
| { readonly type: "response"; readonly response: NativeModelResponse }
|
|
53
70
|
|
|
54
71
|
export interface NativeModelPort {
|
|
@@ -119,10 +136,21 @@ export interface NifraBackendOptions {
|
|
|
119
136
|
readonly model: NativeModelPort
|
|
120
137
|
readonly tools?: readonly NativeTool[]
|
|
121
138
|
readonly approval?: NativeApprovalPort
|
|
122
|
-
/**
|
|
123
|
-
|
|
139
|
+
/**
|
|
140
|
+
* Maximum time a protocol-visible native approval may remain unresolved (default 5 minutes, at
|
|
141
|
+
* most 24 h). `"never"` removes the timer: the approval waits for an answer, a cancel, the end of
|
|
142
|
+
* its turn, or session close.
|
|
143
|
+
*/
|
|
144
|
+
readonly approvalTimeoutMs?: ApprovalTimeout
|
|
124
145
|
readonly maxSteps?: number
|
|
146
|
+
/** Longest message `send` accepts (default 64 KiB). */
|
|
125
147
|
readonly maxMessageChars?: number
|
|
148
|
+
/**
|
|
149
|
+
* Longest reply the model may give in one turn (default 1 MiB); past it the reply is cut with
|
|
150
|
+
* a marker. A reply longer than one event's text travels as consecutive `assistant.message`
|
|
151
|
+
* events, and the model's history keeps it whole.
|
|
152
|
+
*/
|
|
153
|
+
readonly maxReplyChars?: number
|
|
126
154
|
/**
|
|
127
155
|
* Character budget for the conversation a session sends to the model (default 1 MiB). Past it,
|
|
128
156
|
* whole earlier turns are dropped, oldest first; the current turn is always sent in full.
|
|
@@ -152,7 +180,7 @@ interface NativePendingApproval {
|
|
|
152
180
|
readonly action: string
|
|
153
181
|
readonly capability: string
|
|
154
182
|
readonly resolve: (resolution: NativeApprovalResolution) => void
|
|
155
|
-
readonly timer: ReturnType<typeof setTimeout>
|
|
183
|
+
readonly timer: ReturnType<typeof setTimeout> | undefined
|
|
156
184
|
readonly onAbort: () => void
|
|
157
185
|
readonly signal: AbortSignal
|
|
158
186
|
}
|
|
@@ -186,7 +214,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
186
214
|
capabilities: Object.freeze(["sessions", "extensions", "reload", "approvals", "streaming"]),
|
|
187
215
|
})
|
|
188
216
|
private readonly options: Required<
|
|
189
|
-
Pick<NifraBackendOptions, "maxSteps" | "maxMessageChars" | "maxHistoryChars">
|
|
217
|
+
Pick<NifraBackendOptions, "maxSteps" | "maxMessageChars" | "maxReplyChars" | "maxHistoryChars">
|
|
190
218
|
> &
|
|
191
219
|
NifraBackendOptions
|
|
192
220
|
private readonly sessions = new Map<string, NativeSession>()
|
|
@@ -197,6 +225,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
197
225
|
...options,
|
|
198
226
|
maxSteps: options.maxSteps ?? 32,
|
|
199
227
|
maxMessageChars: options.maxMessageChars ?? 64 * 1024,
|
|
228
|
+
maxReplyChars: options.maxReplyChars ?? 1024 * 1024,
|
|
200
229
|
maxHistoryChars: options.maxHistoryChars ?? 1024 * 1024,
|
|
201
230
|
tools: Object.freeze([...(options.tools ?? [])]),
|
|
202
231
|
})
|
|
@@ -208,15 +237,17 @@ export class NifraBackend implements AgentBackend {
|
|
|
208
237
|
throw new RangeError("nifra backend: maxSteps must be between 1 and 512")
|
|
209
238
|
if (!Number.isSafeInteger(this.options.maxMessageChars) || this.options.maxMessageChars < 256)
|
|
210
239
|
throw new RangeError("nifra backend: maxMessageChars must be at least 256")
|
|
240
|
+
if (!Number.isSafeInteger(this.options.maxReplyChars) || this.options.maxReplyChars < 256)
|
|
241
|
+
throw new RangeError("nifra backend: maxReplyChars must be at least 256")
|
|
211
242
|
if (!Number.isSafeInteger(this.options.maxHistoryChars) || this.options.maxHistoryChars < 256)
|
|
212
243
|
throw new RangeError("nifra backend: maxHistoryChars must be at least 256")
|
|
213
244
|
if (
|
|
214
245
|
this.options.approvalTimeoutMs !== undefined &&
|
|
215
|
-
|
|
216
|
-
this.options.approvalTimeoutMs < 1 ||
|
|
217
|
-
this.options.approvalTimeoutMs > 24 * 60 * 60_000)
|
|
246
|
+
!isApprovalTimeout(this.options.approvalTimeoutMs)
|
|
218
247
|
)
|
|
219
|
-
throw new RangeError(
|
|
248
|
+
throw new RangeError(
|
|
249
|
+
'nifra backend: approvalTimeoutMs must be between 1ms and 24h, or "never"',
|
|
250
|
+
)
|
|
220
251
|
}
|
|
221
252
|
|
|
222
253
|
async createSession(input: CreateSessionInput): Promise<AgentSessionSnapshot> {
|
|
@@ -387,9 +418,10 @@ export class NifraBackend implements AgentBackend {
|
|
|
387
418
|
return
|
|
388
419
|
}
|
|
389
420
|
this.update(session, "running", turnId)
|
|
390
|
-
this.emit(session, { type: "turn.started", turnId, prompt: message })
|
|
391
|
-
this.remember(session, { role: "user", text: message })
|
|
392
421
|
try {
|
|
422
|
+
// The event carries a display copy; the model gets the whole message.
|
|
423
|
+
this.emit(session, { type: "turn.started", turnId, prompt: boundAgentText(message) })
|
|
424
|
+
this.remember(session, { role: "user", text: message })
|
|
393
425
|
for (let step = 0; step < this.options.maxSteps; step++) {
|
|
394
426
|
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
395
427
|
this.trimHistory(session)
|
|
@@ -409,9 +441,10 @@ export class NifraBackend implements AgentBackend {
|
|
|
409
441
|
const response = await this.consumeModel(raw, session, stream, turnId, signal)
|
|
410
442
|
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
411
443
|
if (response.type === "text") {
|
|
412
|
-
const text = boundedText(response.text, this.options.
|
|
444
|
+
const text = boundedText(response.text, this.options.maxReplyChars)
|
|
413
445
|
this.remember(session, { role: "assistant", text })
|
|
414
|
-
|
|
446
|
+
for (const piece of splitAgentText(text))
|
|
447
|
+
this.emit(session, { type: "assistant.message", turnId, text: piece })
|
|
415
448
|
this.finish(session, stream)
|
|
416
449
|
return
|
|
417
450
|
}
|
|
@@ -421,22 +454,28 @@ export class NifraBackend implements AgentBackend {
|
|
|
421
454
|
type: "tool.completed",
|
|
422
455
|
turnId,
|
|
423
456
|
callId: crypto.randomUUID(),
|
|
424
|
-
name: response.name,
|
|
457
|
+
name: boundedApprovalToken(response.name) ? response.name : "unknown",
|
|
425
458
|
ok: false,
|
|
426
|
-
error: agentError(
|
|
459
|
+
error: agentError(
|
|
460
|
+
"UNKNOWN_TOOL",
|
|
461
|
+
boundAgentText(`unknown native tool: ${response.name}`, 512),
|
|
462
|
+
),
|
|
427
463
|
})
|
|
428
464
|
this.remember(session, { role: "tool", name: response.name, text: "unknown tool" })
|
|
429
465
|
continue
|
|
430
466
|
}
|
|
431
467
|
const callId = crypto.randomUUID()
|
|
432
468
|
const execute = tool.execute
|
|
433
|
-
|
|
469
|
+
// The event carries a display copy; the approval and the tool get the whole input.
|
|
470
|
+
const started: ToolStartedPayload = {
|
|
434
471
|
type: "tool.started",
|
|
435
472
|
turnId,
|
|
436
473
|
callId,
|
|
437
474
|
name: tool.name,
|
|
438
|
-
|
|
439
|
-
|
|
475
|
+
}
|
|
476
|
+
const shown = boundAgentValue(response.input)
|
|
477
|
+
if (shown !== undefined) started.input = shown
|
|
478
|
+
this.emit(session, started)
|
|
440
479
|
const needsApproval =
|
|
441
480
|
typeof tool.requiresApproval === "function"
|
|
442
481
|
? await tool.requiresApproval(response.input)
|
|
@@ -477,14 +516,12 @@ export class NifraBackend implements AgentBackend {
|
|
|
477
516
|
callId,
|
|
478
517
|
name: tool.name,
|
|
479
518
|
ok: true,
|
|
480
|
-
output: text,
|
|
519
|
+
output: boundAgentText(text, AGENT_MAX_PAYLOAD_STRING),
|
|
481
520
|
})
|
|
482
521
|
} catch (error) {
|
|
483
522
|
if (signal.aborted || !this.isCurrentTurn(session, stream, turnId, signal)) throw error
|
|
484
|
-
const message =
|
|
485
|
-
error instanceof Error ? error.message : error
|
|
486
|
-
this.options.maxMessageChars,
|
|
487
|
-
)
|
|
523
|
+
const message =
|
|
524
|
+
boundedText(error instanceof Error ? error.message : error) || "native tool failed"
|
|
488
525
|
this.remember(session, { role: "tool", name: tool.name, text: message })
|
|
489
526
|
this.emit(session, {
|
|
490
527
|
type: "tool.completed",
|
|
@@ -492,7 +529,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
492
529
|
callId,
|
|
493
530
|
name: tool.name,
|
|
494
531
|
ok: false,
|
|
495
|
-
error: agentError("TOOL_FAILED", message),
|
|
532
|
+
error: agentError("TOOL_FAILED", boundAgentText(message)),
|
|
496
533
|
})
|
|
497
534
|
}
|
|
498
535
|
}
|
|
@@ -511,7 +548,10 @@ export class NifraBackend implements AgentBackend {
|
|
|
511
548
|
type: "session.failed",
|
|
512
549
|
error: agentError(
|
|
513
550
|
"NATIVE_TURN_FAILED",
|
|
514
|
-
|
|
551
|
+
boundAgentText(
|
|
552
|
+
boundedText(error instanceof Error ? error.message : error, 4_096) ||
|
|
553
|
+
"native turn failed",
|
|
554
|
+
),
|
|
515
555
|
),
|
|
516
556
|
recoverable: true,
|
|
517
557
|
})
|
|
@@ -529,11 +569,30 @@ export class NifraBackend implements AgentBackend {
|
|
|
529
569
|
): Promise<NativeModelResponse> {
|
|
530
570
|
if (isAsyncIterable<NativeModelChunk>(raw)) {
|
|
531
571
|
let response: NativeModelResponse | undefined
|
|
572
|
+
// Streamed text stops where the final reply is cut, so the deltas never outgrow it.
|
|
573
|
+
let room = this.options.maxReplyChars
|
|
532
574
|
for await (const chunk of raw) {
|
|
533
575
|
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
534
|
-
if (chunk.type === "
|
|
535
|
-
|
|
536
|
-
|
|
576
|
+
if (chunk.type === "response") {
|
|
577
|
+
response = chunk.response
|
|
578
|
+
continue
|
|
579
|
+
}
|
|
580
|
+
if (chunk.type === "reasoning_delta") {
|
|
581
|
+
const itemId = isEventToken(chunk.itemId) ? { itemId: chunk.itemId } : {}
|
|
582
|
+
for (const piece of splitAgentText(chunk.text))
|
|
583
|
+
this.emit(session, { type: "reasoning.delta", turnId, ...itemId, text: piece })
|
|
584
|
+
continue
|
|
585
|
+
}
|
|
586
|
+
if (chunk.type === "usage") {
|
|
587
|
+
const usage = nativeUsage(chunk)
|
|
588
|
+
if (usage !== undefined) this.emit(session, { type: "context.usage", turnId, ...usage })
|
|
589
|
+
continue
|
|
590
|
+
}
|
|
591
|
+
if (room <= 0) continue
|
|
592
|
+
const text = chunk.text.slice(0, room)
|
|
593
|
+
room -= text.length
|
|
594
|
+
for (const piece of splitAgentText(text))
|
|
595
|
+
this.emit(session, { type: "assistant.delta", turnId, text: piece })
|
|
537
596
|
}
|
|
538
597
|
if (response === undefined) throw new Error("native model stream ended without a response")
|
|
539
598
|
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
@@ -573,13 +632,14 @@ export class NifraBackend implements AgentBackend {
|
|
|
573
632
|
session.closed ? "APPROVAL_CLOSED" : "APPROVAL_CANCELLED",
|
|
574
633
|
)
|
|
575
634
|
}
|
|
576
|
-
const
|
|
577
|
-
|
|
635
|
+
const timeoutMs = this.options.approvalTimeoutMs ?? 5 * 60_000
|
|
636
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
637
|
+
if (timeoutMs !== "never") {
|
|
638
|
+
timer = setTimeout(() => {
|
|
578
639
|
this.settleApproval(session, approvalId, false, "approval timed out", "APPROVAL_TIMEOUT")
|
|
579
|
-
},
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
;(timer as unknown as { unref?: () => void }).unref?.()
|
|
640
|
+
}, timeoutMs)
|
|
641
|
+
;(timer as unknown as { unref?: () => void }).unref?.()
|
|
642
|
+
}
|
|
583
643
|
pending = {
|
|
584
644
|
id: approvalId,
|
|
585
645
|
sessionId: session.id,
|
|
@@ -692,7 +752,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
692
752
|
return
|
|
693
753
|
const stream = session.active
|
|
694
754
|
const turnId = session.turnId
|
|
695
|
-
const stopReason = boundedText(reason, 512) || "cancelled"
|
|
755
|
+
const stopReason = boundAgentText(boundedText(reason, 512) || "cancelled")
|
|
696
756
|
session.cancelled = true
|
|
697
757
|
this.clearTurnSignal(session)
|
|
698
758
|
try {
|
|
@@ -812,6 +872,14 @@ export class NifraBackend implements AgentBackend {
|
|
|
812
872
|
}
|
|
813
873
|
}
|
|
814
874
|
|
|
875
|
+
interface ToolStartedPayload {
|
|
876
|
+
type: "tool.started"
|
|
877
|
+
turnId: string
|
|
878
|
+
callId: string
|
|
879
|
+
name: string
|
|
880
|
+
input?: unknown
|
|
881
|
+
}
|
|
882
|
+
|
|
815
883
|
function messageChars(message: NativeMessage): number {
|
|
816
884
|
return message.text.length + (message.name?.length ?? 0)
|
|
817
885
|
}
|
|
@@ -875,3 +943,38 @@ function removeActiveTurn(
|
|
|
875
943
|
const { activeTurnId: _activeTurnId, ...withoutActiveTurn } = snapshot
|
|
876
944
|
return withoutActiveTurn
|
|
877
945
|
}
|
|
946
|
+
|
|
947
|
+
function isEventToken(value: unknown): value is string {
|
|
948
|
+
return typeof value === "string" && /^[A-Za-z0-9._:/-]{1,128}$/.test(value)
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const USAGE_COUNTS = [
|
|
952
|
+
"windowTokens",
|
|
953
|
+
"inputTokens",
|
|
954
|
+
"outputTokens",
|
|
955
|
+
"cachedInputTokens",
|
|
956
|
+
"reasoningTokens",
|
|
957
|
+
] as const
|
|
958
|
+
|
|
959
|
+
type NativeUsage = {
|
|
960
|
+
-readonly [Key in keyof Omit<Extract<NativeModelChunk, { type: "usage" }>, "type">]: Extract<
|
|
961
|
+
NativeModelChunk,
|
|
962
|
+
{ type: "usage" }
|
|
963
|
+
>[Key]
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// A model port is application code; a count it gets wrong is dropped, never a failed turn.
|
|
967
|
+
function nativeUsage(chunk: Extract<NativeModelChunk, { type: "usage" }>): NativeUsage | undefined {
|
|
968
|
+
if (!isTokenCount(chunk.usedTokens)) return undefined
|
|
969
|
+
const usage: NativeUsage = { usedTokens: chunk.usedTokens }
|
|
970
|
+
for (const key of USAGE_COUNTS) {
|
|
971
|
+
const count = chunk[key]
|
|
972
|
+
if (isTokenCount(count)) usage[key] = count
|
|
973
|
+
}
|
|
974
|
+
if (chunk.estimated === true) usage.estimated = true
|
|
975
|
+
return usage
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function isTokenCount(value: unknown): value is number {
|
|
979
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
|
|
980
|
+
}
|