@nifrajs/coding-agent 4.0.2 → 5.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 +14 -2
- 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/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 +117 -13
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/native.d.ts +22 -0
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +75 -18
- 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 +63 -1
- package/dist/process.js.map +1 -1
- package/dist/rpc.d.ts +23 -0
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +204 -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/cli.ts +125 -93
- package/src/context.ts +12 -20
- package/src/diff.ts +11 -23
- package/src/handoffs.ts +27 -0
- package/src/host.ts +134 -12
- package/src/index.ts +1 -0
- package/src/native.ts +118 -21
- 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 +77 -0
- package/src/rpc.ts +261 -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
|
|
@@ -157,13 +223,11 @@ export class CodingAgentHost {
|
|
|
157
223
|
|
|
158
224
|
private acceptBackendEvent(rawEvent: unknown): AgentEvent {
|
|
159
225
|
const session = this.requireSession()
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
})
|
|
166
|
-
}
|
|
226
|
+
// agentEventProblem re-walks the event, so it runs only once the event is known to be bad.
|
|
227
|
+
if (!isAgentEvent(rawEvent))
|
|
228
|
+
return this.protocolFailure(agentEventProblem(rawEvent) ?? "event is invalid")
|
|
229
|
+
const problem = this.acceptedEventProblem(rawEvent, session.id)
|
|
230
|
+
if (problem !== undefined) return this.protocolFailure(problem)
|
|
167
231
|
const event = Object.freeze({
|
|
168
232
|
...rawEvent,
|
|
169
233
|
sessionId: session.id,
|
|
@@ -210,6 +274,28 @@ export class CodingAgentHost {
|
|
|
210
274
|
return event
|
|
211
275
|
}
|
|
212
276
|
|
|
277
|
+
private protocolFailure(problem: string): AgentEvent {
|
|
278
|
+
return this.createEvent({
|
|
279
|
+
type: "session.failed",
|
|
280
|
+
error: agentError("BACKEND_PROTOCOL", `backend emitted an invalid agent event: ${problem}`),
|
|
281
|
+
recoverable: false,
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private acceptedEventProblem(event: AgentEvent, sessionId: string): string | undefined {
|
|
286
|
+
if (event.sessionId !== sessionId) return "sessionId is not this session's"
|
|
287
|
+
if (!isExtensionEvent(event)) return undefined
|
|
288
|
+
const check = this.extensionChecks.get(event.type)
|
|
289
|
+
if (check === undefined) return undefined
|
|
290
|
+
let accepted = false
|
|
291
|
+
try {
|
|
292
|
+
accepted = check(event) === true
|
|
293
|
+
} catch {
|
|
294
|
+
accepted = false
|
|
295
|
+
}
|
|
296
|
+
return accepted ? undefined : `${event.type} failed its registered check`
|
|
297
|
+
}
|
|
298
|
+
|
|
213
299
|
private async verifyTurn(): Promise<{
|
|
214
300
|
readonly events: readonly AgentEvent[]
|
|
215
301
|
readonly tasks: readonly VerificationRepairTask[]
|
|
@@ -376,6 +462,9 @@ export class CodingAgentHost {
|
|
|
376
462
|
}
|
|
377
463
|
|
|
378
464
|
async stop(reason = "stopped"): Promise<void> {
|
|
465
|
+
// A start still in flight is superseded: wait for it to roll its session back.
|
|
466
|
+
this.lifecycle++
|
|
467
|
+
await this.starting?.catch(() => {})
|
|
379
468
|
if (this.session === undefined) return
|
|
380
469
|
const id = this.session.id
|
|
381
470
|
this.session = undefined
|
|
@@ -420,10 +509,19 @@ export class CodingAgentHost {
|
|
|
420
509
|
approved: boolean,
|
|
421
510
|
reason?: string,
|
|
422
511
|
): Promise<ApprovalDecision | undefined> {
|
|
512
|
+
// The broker decides first: an approval it no longer holds (unknown, expired, already settled,
|
|
513
|
+
// or bound to a coordinate) never reaches the backend.
|
|
514
|
+
this.resolving.add(approvalId)
|
|
515
|
+
let decision: ApprovalDecision | undefined
|
|
516
|
+
try {
|
|
517
|
+
decision = this.approvals.resolve(approvalId, approved, reason)
|
|
518
|
+
} finally {
|
|
519
|
+
this.resolving.delete(approvalId)
|
|
520
|
+
}
|
|
521
|
+
if (decision === undefined) return undefined
|
|
423
522
|
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)
|
|
523
|
+
await this.backend.resolveApproval(this.session.id, approvalId, decision.approved, reason)
|
|
524
|
+
if (this.session !== undefined)
|
|
427
525
|
await this.sessionStore?.append(this.session.id, "approval.resolved", decision, {
|
|
428
526
|
pinned: true,
|
|
429
527
|
})
|
|
@@ -517,8 +615,14 @@ function boundedHostToken(value: unknown): value is string {
|
|
|
517
615
|
return typeof value === "string" && /^[A-Za-z0-9._:/-]{1,128}$/.test(value)
|
|
518
616
|
}
|
|
519
617
|
|
|
618
|
+
function isExtensionEvent(event: AgentEvent): event is AgentExtensionEvent {
|
|
619
|
+
return isAgentExtensionEventType(event.type)
|
|
620
|
+
}
|
|
621
|
+
|
|
520
622
|
/** Default session evidence is content-free; live protocol consumers still receive the full event. */
|
|
521
623
|
function eventEvidence(event: AgentEvent): unknown {
|
|
624
|
+
// An extension event's data is the backend's own content; evidence keeps only where it belongs.
|
|
625
|
+
if (isExtensionEvent(event)) return event.turnId === undefined ? {} : { turnId: event.turnId }
|
|
522
626
|
switch (event.type) {
|
|
523
627
|
case "session.started":
|
|
524
628
|
case "session.updated":
|
|
@@ -533,7 +637,25 @@ function eventEvidence(event: AgentEvent): unknown {
|
|
|
533
637
|
return { turnId: event.turnId }
|
|
534
638
|
case "assistant.delta":
|
|
535
639
|
case "assistant.message":
|
|
640
|
+
case "reasoning.delta":
|
|
536
641
|
return { turnId: event.turnId }
|
|
642
|
+
case "plan.updated":
|
|
643
|
+
return {
|
|
644
|
+
turnId: event.turnId,
|
|
645
|
+
entries: event.entries.length,
|
|
646
|
+
completed: event.entries.filter((entry) => entry.status === "completed").length,
|
|
647
|
+
}
|
|
648
|
+
case "context.usage": {
|
|
649
|
+
const {
|
|
650
|
+
version: _version,
|
|
651
|
+
sessionId: _sessionId,
|
|
652
|
+
seq: _seq,
|
|
653
|
+
at: _at,
|
|
654
|
+
type: _type,
|
|
655
|
+
...counts
|
|
656
|
+
} = event
|
|
657
|
+
return counts
|
|
658
|
+
}
|
|
537
659
|
case "tool.started":
|
|
538
660
|
return { turnId: event.turnId, callId: event.callId, name: event.name }
|
|
539
661
|
case "tool.delta":
|
package/src/index.ts
CHANGED
package/src/native.ts
CHANGED
|
@@ -5,17 +5,21 @@ 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"
|
|
20
24
|
|
|
21
25
|
export interface NativeMessage {
|
|
@@ -49,6 +53,18 @@ export type NativeModelResponse =
|
|
|
49
53
|
|
|
50
54
|
export type NativeModelChunk =
|
|
51
55
|
| { readonly type: "text_delta"; readonly text: string }
|
|
56
|
+
| { readonly type: "reasoning_delta"; readonly text: string; readonly itemId?: string }
|
|
57
|
+
/** Token counts for the request; becomes a `context.usage` event. */
|
|
58
|
+
| {
|
|
59
|
+
readonly type: "usage"
|
|
60
|
+
readonly usedTokens: number
|
|
61
|
+
readonly windowTokens?: number
|
|
62
|
+
readonly inputTokens?: number
|
|
63
|
+
readonly outputTokens?: number
|
|
64
|
+
readonly cachedInputTokens?: number
|
|
65
|
+
readonly reasoningTokens?: number
|
|
66
|
+
readonly estimated?: boolean
|
|
67
|
+
}
|
|
52
68
|
| { readonly type: "response"; readonly response: NativeModelResponse }
|
|
53
69
|
|
|
54
70
|
export interface NativeModelPort {
|
|
@@ -122,7 +138,14 @@ export interface NifraBackendOptions {
|
|
|
122
138
|
/** Maximum time a protocol-visible native approval may remain unresolved. */
|
|
123
139
|
readonly approvalTimeoutMs?: number
|
|
124
140
|
readonly maxSteps?: number
|
|
141
|
+
/** Longest message `send` accepts (default 64 KiB). */
|
|
125
142
|
readonly maxMessageChars?: number
|
|
143
|
+
/**
|
|
144
|
+
* Longest reply the model may give in one turn (default 1 MiB); past it the reply is cut with
|
|
145
|
+
* a marker. A reply longer than one event's text travels as consecutive `assistant.message`
|
|
146
|
+
* events, and the model's history keeps it whole.
|
|
147
|
+
*/
|
|
148
|
+
readonly maxReplyChars?: number
|
|
126
149
|
/**
|
|
127
150
|
* Character budget for the conversation a session sends to the model (default 1 MiB). Past it,
|
|
128
151
|
* whole earlier turns are dropped, oldest first; the current turn is always sent in full.
|
|
@@ -186,7 +209,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
186
209
|
capabilities: Object.freeze(["sessions", "extensions", "reload", "approvals", "streaming"]),
|
|
187
210
|
})
|
|
188
211
|
private readonly options: Required<
|
|
189
|
-
Pick<NifraBackendOptions, "maxSteps" | "maxMessageChars" | "maxHistoryChars">
|
|
212
|
+
Pick<NifraBackendOptions, "maxSteps" | "maxMessageChars" | "maxReplyChars" | "maxHistoryChars">
|
|
190
213
|
> &
|
|
191
214
|
NifraBackendOptions
|
|
192
215
|
private readonly sessions = new Map<string, NativeSession>()
|
|
@@ -197,6 +220,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
197
220
|
...options,
|
|
198
221
|
maxSteps: options.maxSteps ?? 32,
|
|
199
222
|
maxMessageChars: options.maxMessageChars ?? 64 * 1024,
|
|
223
|
+
maxReplyChars: options.maxReplyChars ?? 1024 * 1024,
|
|
200
224
|
maxHistoryChars: options.maxHistoryChars ?? 1024 * 1024,
|
|
201
225
|
tools: Object.freeze([...(options.tools ?? [])]),
|
|
202
226
|
})
|
|
@@ -208,6 +232,8 @@ export class NifraBackend implements AgentBackend {
|
|
|
208
232
|
throw new RangeError("nifra backend: maxSteps must be between 1 and 512")
|
|
209
233
|
if (!Number.isSafeInteger(this.options.maxMessageChars) || this.options.maxMessageChars < 256)
|
|
210
234
|
throw new RangeError("nifra backend: maxMessageChars must be at least 256")
|
|
235
|
+
if (!Number.isSafeInteger(this.options.maxReplyChars) || this.options.maxReplyChars < 256)
|
|
236
|
+
throw new RangeError("nifra backend: maxReplyChars must be at least 256")
|
|
211
237
|
if (!Number.isSafeInteger(this.options.maxHistoryChars) || this.options.maxHistoryChars < 256)
|
|
212
238
|
throw new RangeError("nifra backend: maxHistoryChars must be at least 256")
|
|
213
239
|
if (
|
|
@@ -387,9 +413,10 @@ export class NifraBackend implements AgentBackend {
|
|
|
387
413
|
return
|
|
388
414
|
}
|
|
389
415
|
this.update(session, "running", turnId)
|
|
390
|
-
this.emit(session, { type: "turn.started", turnId, prompt: message })
|
|
391
|
-
this.remember(session, { role: "user", text: message })
|
|
392
416
|
try {
|
|
417
|
+
// The event carries a display copy; the model gets the whole message.
|
|
418
|
+
this.emit(session, { type: "turn.started", turnId, prompt: boundAgentText(message) })
|
|
419
|
+
this.remember(session, { role: "user", text: message })
|
|
393
420
|
for (let step = 0; step < this.options.maxSteps; step++) {
|
|
394
421
|
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
395
422
|
this.trimHistory(session)
|
|
@@ -409,9 +436,10 @@ export class NifraBackend implements AgentBackend {
|
|
|
409
436
|
const response = await this.consumeModel(raw, session, stream, turnId, signal)
|
|
410
437
|
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
411
438
|
if (response.type === "text") {
|
|
412
|
-
const text = boundedText(response.text, this.options.
|
|
439
|
+
const text = boundedText(response.text, this.options.maxReplyChars)
|
|
413
440
|
this.remember(session, { role: "assistant", text })
|
|
414
|
-
|
|
441
|
+
for (const piece of splitAgentText(text))
|
|
442
|
+
this.emit(session, { type: "assistant.message", turnId, text: piece })
|
|
415
443
|
this.finish(session, stream)
|
|
416
444
|
return
|
|
417
445
|
}
|
|
@@ -421,22 +449,28 @@ export class NifraBackend implements AgentBackend {
|
|
|
421
449
|
type: "tool.completed",
|
|
422
450
|
turnId,
|
|
423
451
|
callId: crypto.randomUUID(),
|
|
424
|
-
name: response.name,
|
|
452
|
+
name: boundedApprovalToken(response.name) ? response.name : "unknown",
|
|
425
453
|
ok: false,
|
|
426
|
-
error: agentError(
|
|
454
|
+
error: agentError(
|
|
455
|
+
"UNKNOWN_TOOL",
|
|
456
|
+
boundAgentText(`unknown native tool: ${response.name}`, 512),
|
|
457
|
+
),
|
|
427
458
|
})
|
|
428
459
|
this.remember(session, { role: "tool", name: response.name, text: "unknown tool" })
|
|
429
460
|
continue
|
|
430
461
|
}
|
|
431
462
|
const callId = crypto.randomUUID()
|
|
432
463
|
const execute = tool.execute
|
|
433
|
-
|
|
464
|
+
// The event carries a display copy; the approval and the tool get the whole input.
|
|
465
|
+
const started: ToolStartedPayload = {
|
|
434
466
|
type: "tool.started",
|
|
435
467
|
turnId,
|
|
436
468
|
callId,
|
|
437
469
|
name: tool.name,
|
|
438
|
-
|
|
439
|
-
|
|
470
|
+
}
|
|
471
|
+
const shown = boundAgentValue(response.input)
|
|
472
|
+
if (shown !== undefined) started.input = shown
|
|
473
|
+
this.emit(session, started)
|
|
440
474
|
const needsApproval =
|
|
441
475
|
typeof tool.requiresApproval === "function"
|
|
442
476
|
? await tool.requiresApproval(response.input)
|
|
@@ -477,14 +511,12 @@ export class NifraBackend implements AgentBackend {
|
|
|
477
511
|
callId,
|
|
478
512
|
name: tool.name,
|
|
479
513
|
ok: true,
|
|
480
|
-
output: text,
|
|
514
|
+
output: boundAgentText(text, AGENT_MAX_PAYLOAD_STRING),
|
|
481
515
|
})
|
|
482
516
|
} catch (error) {
|
|
483
517
|
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
|
-
)
|
|
518
|
+
const message =
|
|
519
|
+
boundedText(error instanceof Error ? error.message : error) || "native tool failed"
|
|
488
520
|
this.remember(session, { role: "tool", name: tool.name, text: message })
|
|
489
521
|
this.emit(session, {
|
|
490
522
|
type: "tool.completed",
|
|
@@ -492,7 +524,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
492
524
|
callId,
|
|
493
525
|
name: tool.name,
|
|
494
526
|
ok: false,
|
|
495
|
-
error: agentError("TOOL_FAILED", message),
|
|
527
|
+
error: agentError("TOOL_FAILED", boundAgentText(message)),
|
|
496
528
|
})
|
|
497
529
|
}
|
|
498
530
|
}
|
|
@@ -511,7 +543,10 @@ export class NifraBackend implements AgentBackend {
|
|
|
511
543
|
type: "session.failed",
|
|
512
544
|
error: agentError(
|
|
513
545
|
"NATIVE_TURN_FAILED",
|
|
514
|
-
|
|
546
|
+
boundAgentText(
|
|
547
|
+
boundedText(error instanceof Error ? error.message : error, 4_096) ||
|
|
548
|
+
"native turn failed",
|
|
549
|
+
),
|
|
515
550
|
),
|
|
516
551
|
recoverable: true,
|
|
517
552
|
})
|
|
@@ -529,11 +564,30 @@ export class NifraBackend implements AgentBackend {
|
|
|
529
564
|
): Promise<NativeModelResponse> {
|
|
530
565
|
if (isAsyncIterable<NativeModelChunk>(raw)) {
|
|
531
566
|
let response: NativeModelResponse | undefined
|
|
567
|
+
// Streamed text stops where the final reply is cut, so the deltas never outgrow it.
|
|
568
|
+
let room = this.options.maxReplyChars
|
|
532
569
|
for await (const chunk of raw) {
|
|
533
570
|
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
534
|
-
if (chunk.type === "
|
|
535
|
-
|
|
536
|
-
|
|
571
|
+
if (chunk.type === "response") {
|
|
572
|
+
response = chunk.response
|
|
573
|
+
continue
|
|
574
|
+
}
|
|
575
|
+
if (chunk.type === "reasoning_delta") {
|
|
576
|
+
const itemId = isEventToken(chunk.itemId) ? { itemId: chunk.itemId } : {}
|
|
577
|
+
for (const piece of splitAgentText(chunk.text))
|
|
578
|
+
this.emit(session, { type: "reasoning.delta", turnId, ...itemId, text: piece })
|
|
579
|
+
continue
|
|
580
|
+
}
|
|
581
|
+
if (chunk.type === "usage") {
|
|
582
|
+
const usage = nativeUsage(chunk)
|
|
583
|
+
if (usage !== undefined) this.emit(session, { type: "context.usage", turnId, ...usage })
|
|
584
|
+
continue
|
|
585
|
+
}
|
|
586
|
+
if (room <= 0) continue
|
|
587
|
+
const text = chunk.text.slice(0, room)
|
|
588
|
+
room -= text.length
|
|
589
|
+
for (const piece of splitAgentText(text))
|
|
590
|
+
this.emit(session, { type: "assistant.delta", turnId, text: piece })
|
|
537
591
|
}
|
|
538
592
|
if (response === undefined) throw new Error("native model stream ended without a response")
|
|
539
593
|
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
@@ -692,7 +746,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
692
746
|
return
|
|
693
747
|
const stream = session.active
|
|
694
748
|
const turnId = session.turnId
|
|
695
|
-
const stopReason = boundedText(reason, 512) || "cancelled"
|
|
749
|
+
const stopReason = boundAgentText(boundedText(reason, 512) || "cancelled")
|
|
696
750
|
session.cancelled = true
|
|
697
751
|
this.clearTurnSignal(session)
|
|
698
752
|
try {
|
|
@@ -812,6 +866,14 @@ export class NifraBackend implements AgentBackend {
|
|
|
812
866
|
}
|
|
813
867
|
}
|
|
814
868
|
|
|
869
|
+
interface ToolStartedPayload {
|
|
870
|
+
type: "tool.started"
|
|
871
|
+
turnId: string
|
|
872
|
+
callId: string
|
|
873
|
+
name: string
|
|
874
|
+
input?: unknown
|
|
875
|
+
}
|
|
876
|
+
|
|
815
877
|
function messageChars(message: NativeMessage): number {
|
|
816
878
|
return message.text.length + (message.name?.length ?? 0)
|
|
817
879
|
}
|
|
@@ -875,3 +937,38 @@ function removeActiveTurn(
|
|
|
875
937
|
const { activeTurnId: _activeTurnId, ...withoutActiveTurn } = snapshot
|
|
876
938
|
return withoutActiveTurn
|
|
877
939
|
}
|
|
940
|
+
|
|
941
|
+
function isEventToken(value: unknown): value is string {
|
|
942
|
+
return typeof value === "string" && /^[A-Za-z0-9._:/-]{1,128}$/.test(value)
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const USAGE_COUNTS = [
|
|
946
|
+
"windowTokens",
|
|
947
|
+
"inputTokens",
|
|
948
|
+
"outputTokens",
|
|
949
|
+
"cachedInputTokens",
|
|
950
|
+
"reasoningTokens",
|
|
951
|
+
] as const
|
|
952
|
+
|
|
953
|
+
type NativeUsage = {
|
|
954
|
+
-readonly [Key in keyof Omit<Extract<NativeModelChunk, { type: "usage" }>, "type">]: Extract<
|
|
955
|
+
NativeModelChunk,
|
|
956
|
+
{ type: "usage" }
|
|
957
|
+
>[Key]
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// A model port is application code; a count it gets wrong is dropped, never a failed turn.
|
|
961
|
+
function nativeUsage(chunk: Extract<NativeModelChunk, { type: "usage" }>): NativeUsage | undefined {
|
|
962
|
+
if (!isTokenCount(chunk.usedTokens)) return undefined
|
|
963
|
+
const usage: NativeUsage = { usedTokens: chunk.usedTokens }
|
|
964
|
+
for (const key of USAGE_COUNTS) {
|
|
965
|
+
const count = chunk[key]
|
|
966
|
+
if (isTokenCount(count)) usage[key] = count
|
|
967
|
+
}
|
|
968
|
+
if (chunk.estimated === true) usage.estimated = true
|
|
969
|
+
return usage
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function isTokenCount(value: unknown): value is number {
|
|
973
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
|
|
974
|
+
}
|
|
@@ -15,7 +15,11 @@ export const DISPATCH_JOB_NAME = "nifra.agent.run-node"
|
|
|
15
15
|
export const MAX_DISPATCH_STRING = 256
|
|
16
16
|
export const MAX_DISPATCH_ATTEMPTS = 16
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* `before-effect`: no side effect has happened, so a redelivery may run the effect. `effect-started`:
|
|
20
|
+
* the effect was invoked and may have acted, so a redelivery needs a proof. `after-effect`: it acted.
|
|
21
|
+
*/
|
|
22
|
+
export type DispatchBoundary = "before-effect" | "effect-started" | "after-effect"
|
|
19
23
|
export type DispatchState =
|
|
20
24
|
| "queued"
|
|
21
25
|
| "leased"
|
|
@@ -300,7 +304,7 @@ export function parseRunCheckpoint(value: unknown): RunCheckpoint {
|
|
|
300
304
|
if (input.version !== RUN_DISPATCH_VERSION)
|
|
301
305
|
throw new TypeError("run checkpoint.version is unsupported")
|
|
302
306
|
const boundary = input.boundary
|
|
303
|
-
if (boundary !== "before-effect" && boundary !== "after-effect")
|
|
307
|
+
if (boundary !== "before-effect" && boundary !== "effect-started" && boundary !== "after-effect")
|
|
304
308
|
throw new TypeError("run checkpoint.boundary is invalid")
|
|
305
309
|
return Object.freeze({
|
|
306
310
|
version: RUN_DISPATCH_VERSION,
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* evidence only, and the file store persists exactly the content-free record it parsed.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { createHash } from "node:crypto"
|
|
13
14
|
import { appendFile } from "node:fs/promises"
|
|
14
15
|
import type { ArtifactRef, RunEvidence } from "@nifrajs/agent-protocol"
|
|
15
16
|
import { assertEvidenceSize, parseRunEvidence } from "@nifrajs/agent-protocol"
|
|
@@ -41,12 +42,10 @@ export interface EvidenceStore {
|
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
const DEFAULT_MAX_LIVE = 1024
|
|
44
|
-
const encoder = new TextEncoder()
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
46
|
+
// Synchronous: `crypto.subtle` hands every record to a worker thread and back, which on a loaded
|
|
47
|
+
// machine made a 10k-record run take seconds.
|
|
48
|
+
const sha256Bytes = (text: string): Uint8Array => createHash("sha256").update(text).digest()
|
|
50
49
|
|
|
51
50
|
/**
|
|
52
51
|
* The identity of one record for the terminal digest: node outcome only. `seq`, `durationMs`, and
|
|
@@ -97,7 +96,7 @@ class EvidenceAccumulator {
|
|
|
97
96
|
} else this.failedCount++
|
|
98
97
|
if (record.artifacts !== undefined)
|
|
99
98
|
for (const ref of record.artifacts) this.artifactRefs.push(ref)
|
|
100
|
-
const hash =
|
|
99
|
+
const hash = sha256Bytes(digestKey(record))
|
|
101
100
|
let carry = 0
|
|
102
101
|
for (let i = 31; i >= 0; i--) {
|
|
103
102
|
const sum = (this.acc[i] as number) + (hash[i] as number) + carry
|