@nifrajs/coding-agent 3.2.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/EXTENSIONS.md +25 -0
- package/PERFORMANCE.md +19 -0
- package/README.md +69 -0
- package/REGISTRY.md +15 -0
- package/SECURITY.md +32 -0
- package/dist/approvals.d.ts +70 -0
- package/dist/approvals.d.ts.map +1 -0
- package/dist/approvals.js +174 -0
- package/dist/approvals.js.map +1 -0
- package/dist/capabilities.d.ts +20 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +49 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +380 -0
- package/dist/cli.js.map +1 -0
- package/dist/context.d.ts +17 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +66 -0
- package/dist/context.js.map +1 -0
- package/dist/deployment-adapters.d.ts +40 -0
- package/dist/deployment-adapters.d.ts.map +1 -0
- package/dist/deployment-adapters.js +147 -0
- package/dist/deployment-adapters.js.map +1 -0
- package/dist/diff.d.ts +17 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +61 -0
- package/dist/diff.js.map +1 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +35 -0
- package/dist/errors.js.map +1 -0
- package/dist/extensions.d.ts +104 -0
- package/dist/extensions.d.ts.map +1 -0
- package/dist/extensions.js +284 -0
- package/dist/extensions.js.map +1 -0
- package/dist/handoffs.d.ts +97 -0
- package/dist/handoffs.d.ts.map +1 -0
- package/dist/handoffs.js +199 -0
- package/dist/handoffs.js.map +1 -0
- package/dist/healing.d.ts +56 -0
- package/dist/healing.d.ts.map +1 -0
- package/dist/healing.js +54 -0
- package/dist/healing.js.map +1 -0
- package/dist/host.d.ts +61 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +369 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/isolated-worker.d.ts +2 -0
- package/dist/isolated-worker.d.ts.map +1 -0
- package/dist/isolated-worker.js +155 -0
- package/dist/isolated-worker.js.map +1 -0
- package/dist/isolated.d.ts +52 -0
- package/dist/isolated.d.ts.map +1 -0
- package/dist/isolated.js +293 -0
- package/dist/isolated.js.map +1 -0
- package/dist/native.d.ts +92 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +339 -0
- package/dist/native.js.map +1 -0
- package/dist/orchestration/artifact-port.d.ts +30 -0
- package/dist/orchestration/artifact-port.d.ts.map +1 -0
- package/dist/orchestration/artifact-port.js +59 -0
- package/dist/orchestration/artifact-port.js.map +1 -0
- package/dist/orchestration/catalog.d.ts +71 -0
- package/dist/orchestration/catalog.d.ts.map +1 -0
- package/dist/orchestration/catalog.js +46 -0
- package/dist/orchestration/catalog.js.map +1 -0
- package/dist/orchestration/compile.d.ts +59 -0
- package/dist/orchestration/compile.d.ts.map +1 -0
- package/dist/orchestration/compile.js +268 -0
- package/dist/orchestration/compile.js.map +1 -0
- package/dist/orchestration/dispatch.d.ts +115 -0
- package/dist/orchestration/dispatch.d.ts.map +1 -0
- package/dist/orchestration/dispatch.js +220 -0
- package/dist/orchestration/dispatch.js.map +1 -0
- package/dist/orchestration/durable-jobs.d.ts +35 -0
- package/dist/orchestration/durable-jobs.d.ts.map +1 -0
- package/dist/orchestration/durable-jobs.js +273 -0
- package/dist/orchestration/durable-jobs.js.map +1 -0
- package/dist/orchestration/effect-key.d.ts +23 -0
- package/dist/orchestration/effect-key.d.ts.map +1 -0
- package/dist/orchestration/effect-key.js +24 -0
- package/dist/orchestration/effect-key.js.map +1 -0
- package/dist/orchestration/evidence-store.d.ts +75 -0
- package/dist/orchestration/evidence-store.d.ts.map +1 -0
- package/dist/orchestration/evidence-store.js +183 -0
- package/dist/orchestration/evidence-store.js.map +1 -0
- package/dist/orchestration/hash.d.ts +12 -0
- package/dist/orchestration/hash.d.ts.map +1 -0
- package/dist/orchestration/hash.js +43 -0
- package/dist/orchestration/hash.js.map +1 -0
- package/dist/orchestration/host.d.ts +121 -0
- package/dist/orchestration/host.d.ts.map +1 -0
- package/dist/orchestration/host.js +334 -0
- package/dist/orchestration/host.js.map +1 -0
- package/dist/orchestration/index.d.ts +20 -0
- package/dist/orchestration/index.d.ts.map +1 -0
- package/dist/orchestration/index.js +19 -0
- package/dist/orchestration/index.js.map +1 -0
- package/dist/orchestration/policy.d.ts +63 -0
- package/dist/orchestration/policy.d.ts.map +1 -0
- package/dist/orchestration/policy.js +80 -0
- package/dist/orchestration/policy.js.map +1 -0
- package/dist/orchestration/recovery.d.ts +87 -0
- package/dist/orchestration/recovery.d.ts.map +1 -0
- package/dist/orchestration/recovery.js +197 -0
- package/dist/orchestration/recovery.js.map +1 -0
- package/dist/orchestration/tracer.d.ts +31 -0
- package/dist/orchestration/tracer.d.ts.map +1 -0
- package/dist/orchestration/tracer.js +141 -0
- package/dist/orchestration/tracer.js.map +1 -0
- package/dist/plans.d.ts +35 -0
- package/dist/plans.d.ts.map +1 -0
- package/dist/plans.js +63 -0
- package/dist/plans.js.map +1 -0
- package/dist/presets.d.ts +10 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +70 -0
- package/dist/presets.js.map +1 -0
- package/dist/process.d.ts +6 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +42 -0
- package/dist/process.js.map +1 -0
- package/dist/registry.d.ts +37 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +51 -0
- package/dist/registry.js.map +1 -0
- package/dist/replay.d.ts +24 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +154 -0
- package/dist/replay.js.map +1 -0
- package/dist/rpc.d.ts +51 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +412 -0
- package/dist/rpc.js.map +1 -0
- package/dist/session-migration.d.ts +59 -0
- package/dist/session-migration.d.ts.map +1 -0
- package/dist/session-migration.js +315 -0
- package/dist/session-migration.js.map +1 -0
- package/dist/sessions.d.ts +74 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +222 -0
- package/dist/sessions.js.map +1 -0
- package/dist/subagents.d.ts +51 -0
- package/dist/subagents.d.ts.map +1 -0
- package/dist/subagents.js +141 -0
- package/dist/subagents.js.map +1 -0
- package/dist/tools.d.ts +13 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +37 -0
- package/dist/tools.js.map +1 -0
- package/dist/ui.d.ts +42 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +93 -0
- package/dist/ui.js.map +1 -0
- package/dist/verification.d.ts +34 -0
- package/dist/verification.d.ts.map +1 -0
- package/dist/verification.js +91 -0
- package/dist/verification.js.map +1 -0
- package/dist/workflows.d.ts +98 -0
- package/dist/workflows.d.ts.map +1 -0
- package/dist/workflows.js +174 -0
- package/dist/workflows.js.map +1 -0
- package/package.json +84 -0
- package/src/approvals.ts +249 -0
- package/src/capabilities.ts +74 -0
- package/src/cli.ts +405 -0
- package/src/context.ts +81 -0
- package/src/deployment-adapters.ts +181 -0
- package/src/diff.ts +80 -0
- package/src/errors.ts +46 -0
- package/src/extensions.ts +402 -0
- package/src/handoffs.ts +299 -0
- package/src/healing.ts +93 -0
- package/src/host.ts +465 -0
- package/src/index.ts +165 -0
- package/src/isolated-worker.ts +177 -0
- package/src/isolated.ts +344 -0
- package/src/native.ts +478 -0
- package/src/orchestration/artifact-port.ts +75 -0
- package/src/orchestration/catalog.ts +102 -0
- package/src/orchestration/compile.ts +373 -0
- package/src/orchestration/dispatch.ts +383 -0
- package/src/orchestration/durable-jobs.ts +374 -0
- package/src/orchestration/effect-key.ts +35 -0
- package/src/orchestration/evidence-store.ts +233 -0
- package/src/orchestration/hash.ts +43 -0
- package/src/orchestration/host.ts +445 -0
- package/src/orchestration/index.ts +131 -0
- package/src/orchestration/policy.ts +116 -0
- package/src/orchestration/recovery.ts +271 -0
- package/src/orchestration/tracer.ts +177 -0
- package/src/plans.ts +105 -0
- package/src/presets.ts +90 -0
- package/src/process.ts +47 -0
- package/src/registry.ts +80 -0
- package/src/replay.ts +190 -0
- package/src/rpc.ts +598 -0
- package/src/session-migration.ts +401 -0
- package/src/sessions.ts +303 -0
- package/src/subagents.ts +210 -0
- package/src/tools.ts +52 -0
- package/src/ui.ts +137 -0
- package/src/verification.ts +133 -0
- package/src/workflows.ts +279 -0
package/src/native.ts
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ModelGateway,
|
|
3
|
+
type ModelRoutePolicy,
|
|
4
|
+
runModelGateway,
|
|
5
|
+
structuredOutputParser,
|
|
6
|
+
} from "@nifrajs/agent"
|
|
7
|
+
import {
|
|
8
|
+
type AgentBackend,
|
|
9
|
+
type AgentBackendInfo,
|
|
10
|
+
type AgentEvent,
|
|
11
|
+
type AgentEventStream,
|
|
12
|
+
type AgentSessionSnapshot,
|
|
13
|
+
agentError,
|
|
14
|
+
type CreateSessionInput,
|
|
15
|
+
createAgentEventStream,
|
|
16
|
+
type ReloadResult,
|
|
17
|
+
type SendMessageInput,
|
|
18
|
+
} from "@nifrajs/agent-protocol"
|
|
19
|
+
|
|
20
|
+
export interface NativeMessage {
|
|
21
|
+
readonly role: "user" | "assistant" | "tool"
|
|
22
|
+
readonly text: string
|
|
23
|
+
readonly name?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface NativeTool {
|
|
27
|
+
readonly name: string
|
|
28
|
+
readonly description: string
|
|
29
|
+
readonly capabilities?: readonly string[]
|
|
30
|
+
readonly requiresApproval?: boolean | ((input: unknown) => boolean | PromiseLike<boolean>)
|
|
31
|
+
readonly execute: (
|
|
32
|
+
input: unknown,
|
|
33
|
+
context: { readonly cwd: string; readonly signal: AbortSignal },
|
|
34
|
+
) => unknown | PromiseLike<unknown>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface NativeModelRequest {
|
|
38
|
+
readonly sessionId: string
|
|
39
|
+
readonly cwd: string
|
|
40
|
+
readonly messages: readonly NativeMessage[]
|
|
41
|
+
readonly tools: readonly Pick<NativeTool, "name" | "description" | "capabilities">[]
|
|
42
|
+
readonly signal: AbortSignal
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type NativeModelResponse =
|
|
46
|
+
| { readonly type: "text"; readonly text: string }
|
|
47
|
+
| { readonly type: "tool"; readonly name: string; readonly input: unknown }
|
|
48
|
+
|
|
49
|
+
export type NativeModelChunk =
|
|
50
|
+
| { readonly type: "text_delta"; readonly text: string }
|
|
51
|
+
| { readonly type: "response"; readonly response: NativeModelResponse }
|
|
52
|
+
|
|
53
|
+
export interface NativeModelPort {
|
|
54
|
+
complete(
|
|
55
|
+
request: NativeModelRequest,
|
|
56
|
+
): NativeModelResponse | PromiseLike<NativeModelResponse> | AsyncIterable<NativeModelChunk>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface NativeGatewayModelPortOptions {
|
|
60
|
+
readonly gateway: ModelGateway
|
|
61
|
+
readonly policy: ModelRoutePolicy
|
|
62
|
+
readonly routeId?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Adapt the provider-neutral gateway to the native backend without making it the default path. */
|
|
66
|
+
export function createNativeGatewayModelPort(
|
|
67
|
+
options: NativeGatewayModelPortOptions,
|
|
68
|
+
): NativeModelPort {
|
|
69
|
+
const parser = structuredOutputParser<NativeModelResponse>((value) =>
|
|
70
|
+
parseNativeModelResponse(value),
|
|
71
|
+
)
|
|
72
|
+
return {
|
|
73
|
+
complete(request) {
|
|
74
|
+
return runModelGateway(
|
|
75
|
+
options.gateway,
|
|
76
|
+
{
|
|
77
|
+
input: request,
|
|
78
|
+
...(options.routeId === undefined ? {} : { routeId: options.routeId }),
|
|
79
|
+
parser,
|
|
80
|
+
signal: request.signal,
|
|
81
|
+
},
|
|
82
|
+
options.policy,
|
|
83
|
+
).then((result) => {
|
|
84
|
+
if (!result.ok) throw new Error(`native gateway failed: ${result.error.code}`)
|
|
85
|
+
return result.output
|
|
86
|
+
})
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseNativeModelResponse(value: unknown): NativeModelResponse {
|
|
92
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
93
|
+
throw new TypeError("native model response is invalid")
|
|
94
|
+
const record = value as Record<string, unknown>
|
|
95
|
+
if (record.type === "text" && typeof record.text === "string")
|
|
96
|
+
return Object.freeze({ type: "text", text: record.text })
|
|
97
|
+
if (record.type === "tool" && typeof record.name === "string" && Object.hasOwn(record, "input"))
|
|
98
|
+
return Object.freeze({ type: "tool", name: record.name, input: record.input })
|
|
99
|
+
throw new TypeError("native model response is invalid")
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface NativeApprovalPort {
|
|
103
|
+
request(input: {
|
|
104
|
+
readonly sessionId: string
|
|
105
|
+
readonly tool: NativeTool
|
|
106
|
+
readonly input: unknown
|
|
107
|
+
readonly signal: AbortSignal
|
|
108
|
+
}): boolean | PromiseLike<boolean>
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface NifraBackendOptions {
|
|
112
|
+
readonly model: NativeModelPort
|
|
113
|
+
readonly tools?: readonly NativeTool[]
|
|
114
|
+
readonly approval?: NativeApprovalPort
|
|
115
|
+
readonly maxSteps?: number
|
|
116
|
+
readonly maxMessageChars?: number
|
|
117
|
+
readonly now?: () => number
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface NativeSession {
|
|
121
|
+
readonly id: string
|
|
122
|
+
readonly cwd: string
|
|
123
|
+
readonly messages: NativeMessage[]
|
|
124
|
+
controller: AbortController
|
|
125
|
+
snapshot: AgentSessionSnapshot
|
|
126
|
+
active: AgentEventStream | undefined
|
|
127
|
+
seq: number
|
|
128
|
+
extensionRevision: number
|
|
129
|
+
closed: boolean
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Small provider port for a future Nifra-native backend.
|
|
134
|
+
*
|
|
135
|
+
* It deliberately knows only messages, tools, and the protocol. Provider SDKs, credentials, UI,
|
|
136
|
+
* and framework packages stay outside this module. A model may return one response or stream
|
|
137
|
+
* deltas followed by a final response.
|
|
138
|
+
*/
|
|
139
|
+
export class NifraBackend implements AgentBackend {
|
|
140
|
+
readonly info: AgentBackendInfo = Object.freeze({
|
|
141
|
+
name: "nifra",
|
|
142
|
+
capabilities: Object.freeze(["sessions", "extensions", "reload", "approvals", "streaming"]),
|
|
143
|
+
})
|
|
144
|
+
private readonly options: Required<Pick<NifraBackendOptions, "maxSteps" | "maxMessageChars">> &
|
|
145
|
+
NifraBackendOptions
|
|
146
|
+
private readonly sessions = new Map<string, NativeSession>()
|
|
147
|
+
|
|
148
|
+
constructor(options: NifraBackendOptions) {
|
|
149
|
+
this.options = Object.freeze({
|
|
150
|
+
...options,
|
|
151
|
+
maxSteps: options.maxSteps ?? 32,
|
|
152
|
+
maxMessageChars: options.maxMessageChars ?? 64 * 1024,
|
|
153
|
+
tools: Object.freeze([...(options.tools ?? [])]),
|
|
154
|
+
})
|
|
155
|
+
if (
|
|
156
|
+
!Number.isSafeInteger(this.options.maxSteps) ||
|
|
157
|
+
this.options.maxSteps < 1 ||
|
|
158
|
+
this.options.maxSteps > 512
|
|
159
|
+
)
|
|
160
|
+
throw new RangeError("nifra backend: maxSteps must be between 1 and 512")
|
|
161
|
+
if (!Number.isSafeInteger(this.options.maxMessageChars) || this.options.maxMessageChars < 256)
|
|
162
|
+
throw new RangeError("nifra backend: maxMessageChars must be at least 256")
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async createSession(input: CreateSessionInput): Promise<AgentSessionSnapshot> {
|
|
166
|
+
const id = input.sessionId ?? crypto.randomUUID()
|
|
167
|
+
if (!/^[a-zA-Z0-9._:-]{1,128}$/.test(id))
|
|
168
|
+
throw new TypeError("nifra backend: sessionId must be a bounded token")
|
|
169
|
+
if (this.sessions.has(id)) throw new Error(`nifra backend: session already exists: ${id}`)
|
|
170
|
+
const now = this.now()
|
|
171
|
+
const snapshot: AgentSessionSnapshot = {
|
|
172
|
+
version: 1,
|
|
173
|
+
id,
|
|
174
|
+
backend: this.info.name,
|
|
175
|
+
cwd: input.cwd,
|
|
176
|
+
status: "idle",
|
|
177
|
+
createdAt: now,
|
|
178
|
+
updatedAt: now,
|
|
179
|
+
lastSeq: 0,
|
|
180
|
+
capabilities: Object.freeze([...(input.capabilities ?? this.info.capabilities)]),
|
|
181
|
+
}
|
|
182
|
+
const session: NativeSession = {
|
|
183
|
+
id,
|
|
184
|
+
cwd: input.cwd,
|
|
185
|
+
messages: [],
|
|
186
|
+
controller: new AbortController(),
|
|
187
|
+
snapshot,
|
|
188
|
+
active: undefined,
|
|
189
|
+
seq: 0,
|
|
190
|
+
extensionRevision: 0,
|
|
191
|
+
closed: false,
|
|
192
|
+
}
|
|
193
|
+
this.sessions.set(id, session)
|
|
194
|
+
return snapshot
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
send(input: SendMessageInput): AsyncIterable<AgentEvent> {
|
|
198
|
+
const session = this.requireSession(input.sessionId)
|
|
199
|
+
if (session.closed) return failedStream(agentError("SESSION_CLOSED", "Nifra session is closed"))
|
|
200
|
+
if (session.active !== undefined)
|
|
201
|
+
return failedStream(agentError("SESSION_BUSY", "Nifra session is busy"))
|
|
202
|
+
if (input.message.length === 0 || input.message.length > this.options.maxMessageChars)
|
|
203
|
+
return failedStream(
|
|
204
|
+
agentError("MESSAGE_BOUNDED", "message is empty or exceeds the configured limit"),
|
|
205
|
+
)
|
|
206
|
+
const stream = createAgentEventStream()
|
|
207
|
+
session.active = stream
|
|
208
|
+
session.controller = new AbortController()
|
|
209
|
+
const signal = session.controller.signal
|
|
210
|
+
if (input.signal !== undefined) {
|
|
211
|
+
const externalSignal = input.signal
|
|
212
|
+
if (externalSignal.aborted) session.controller.abort(externalSignal.reason)
|
|
213
|
+
else
|
|
214
|
+
externalSignal.addEventListener(
|
|
215
|
+
"abort",
|
|
216
|
+
() => session.controller.abort(externalSignal.reason),
|
|
217
|
+
{ once: true },
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
void this.run(session, input.message, stream, signal)
|
|
221
|
+
return stream
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async cancel(sessionId: string, reason = "cancelled"): Promise<void> {
|
|
225
|
+
const session = this.requireSession(sessionId)
|
|
226
|
+
if (session.closed) return
|
|
227
|
+
const stream = session.active
|
|
228
|
+
session.controller.abort(reason)
|
|
229
|
+
this.update(session, "stopped")
|
|
230
|
+
this.emit(session, { type: "session.stopped", reason })
|
|
231
|
+
stream?.complete()
|
|
232
|
+
session.active = undefined
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async snapshot(sessionId: string): Promise<AgentSessionSnapshot> {
|
|
236
|
+
return this.requireSession(sessionId).snapshot
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async reload(sessionId: string): Promise<ReloadResult> {
|
|
240
|
+
const session = this.requireSession(sessionId)
|
|
241
|
+
if (session.active !== undefined)
|
|
242
|
+
return {
|
|
243
|
+
revision: session.snapshot.extensionRevision ?? "unchanged",
|
|
244
|
+
loaded: [],
|
|
245
|
+
disabled: [],
|
|
246
|
+
rolledBack: false,
|
|
247
|
+
error: agentError("SESSION_BUSY", "Nifra session is busy"),
|
|
248
|
+
}
|
|
249
|
+
session.extensionRevision += 1
|
|
250
|
+
const revision = `nifra:${session.extensionRevision}`
|
|
251
|
+
session.snapshot = Object.freeze({
|
|
252
|
+
...session.snapshot,
|
|
253
|
+
extensionRevision: revision,
|
|
254
|
+
updatedAt: this.now(),
|
|
255
|
+
})
|
|
256
|
+
return { revision, loaded: [], disabled: [], rolledBack: false }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async close(sessionId: string): Promise<void> {
|
|
260
|
+
const session = this.requireSession(sessionId)
|
|
261
|
+
if (session.closed) return
|
|
262
|
+
session.closed = true
|
|
263
|
+
session.controller.abort("closed")
|
|
264
|
+
session.active?.complete()
|
|
265
|
+
session.active = undefined
|
|
266
|
+
this.sessions.delete(sessionId)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private async run(
|
|
270
|
+
session: NativeSession,
|
|
271
|
+
message: string,
|
|
272
|
+
stream: AgentEventStream,
|
|
273
|
+
signal: AbortSignal,
|
|
274
|
+
): Promise<void> {
|
|
275
|
+
const turnId = crypto.randomUUID()
|
|
276
|
+
this.update(session, "running", turnId)
|
|
277
|
+
this.emit(session, { type: "turn.started", turnId, prompt: message })
|
|
278
|
+
session.messages.push({ role: "user", text: message })
|
|
279
|
+
try {
|
|
280
|
+
for (let step = 0; step < this.options.maxSteps; step++) {
|
|
281
|
+
if (signal.aborted) throw new Error("native turn cancelled")
|
|
282
|
+
const raw = this.options.model.complete({
|
|
283
|
+
sessionId: session.id,
|
|
284
|
+
cwd: session.cwd,
|
|
285
|
+
messages: Object.freeze(session.messages.map((item) => Object.freeze({ ...item }))),
|
|
286
|
+
tools: Object.freeze(
|
|
287
|
+
this.options.tools!.map((tool) => ({
|
|
288
|
+
name: tool.name,
|
|
289
|
+
description: tool.description,
|
|
290
|
+
...(tool.capabilities === undefined ? {} : { capabilities: tool.capabilities }),
|
|
291
|
+
})),
|
|
292
|
+
),
|
|
293
|
+
signal,
|
|
294
|
+
})
|
|
295
|
+
const response = await this.consumeModel(raw, session, turnId, signal)
|
|
296
|
+
if (response.type === "text") {
|
|
297
|
+
session.messages.push({ role: "assistant", text: response.text })
|
|
298
|
+
this.emit(session, { type: "assistant.message", turnId, text: response.text })
|
|
299
|
+
this.finish(session, stream)
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
const tool = this.options.tools!.find((candidate) => candidate.name === response.name)
|
|
303
|
+
if (tool === undefined) {
|
|
304
|
+
this.emit(session, {
|
|
305
|
+
type: "tool.completed",
|
|
306
|
+
turnId,
|
|
307
|
+
callId: crypto.randomUUID(),
|
|
308
|
+
name: response.name,
|
|
309
|
+
ok: false,
|
|
310
|
+
error: agentError("UNKNOWN_TOOL", `unknown native tool: ${response.name}`),
|
|
311
|
+
})
|
|
312
|
+
session.messages.push({ role: "tool", name: response.name, text: "unknown tool" })
|
|
313
|
+
continue
|
|
314
|
+
}
|
|
315
|
+
const callId = crypto.randomUUID()
|
|
316
|
+
this.emit(session, {
|
|
317
|
+
type: "tool.started",
|
|
318
|
+
turnId,
|
|
319
|
+
callId,
|
|
320
|
+
name: tool.name,
|
|
321
|
+
input: response.input,
|
|
322
|
+
})
|
|
323
|
+
const needsApproval =
|
|
324
|
+
typeof tool.requiresApproval === "function"
|
|
325
|
+
? await tool.requiresApproval(response.input)
|
|
326
|
+
: tool.requiresApproval === true
|
|
327
|
+
if (needsApproval) {
|
|
328
|
+
if (
|
|
329
|
+
this.options.approval === undefined ||
|
|
330
|
+
!(await this.options.approval.request({
|
|
331
|
+
sessionId: session.id,
|
|
332
|
+
tool,
|
|
333
|
+
input: response.input,
|
|
334
|
+
signal,
|
|
335
|
+
}))
|
|
336
|
+
) {
|
|
337
|
+
this.emit(session, {
|
|
338
|
+
type: "tool.completed",
|
|
339
|
+
turnId,
|
|
340
|
+
callId,
|
|
341
|
+
name: tool.name,
|
|
342
|
+
ok: false,
|
|
343
|
+
error: agentError("APPROVAL_DENIED", `approval denied for ${tool.name}`),
|
|
344
|
+
})
|
|
345
|
+
session.messages.push({ role: "tool", name: tool.name, text: "approval denied" })
|
|
346
|
+
continue
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
const output = await tool.execute(response.input, { cwd: session.cwd, signal })
|
|
351
|
+
const text = boundedText(output)
|
|
352
|
+
session.messages.push({ role: "tool", name: tool.name, text })
|
|
353
|
+
this.emit(session, {
|
|
354
|
+
type: "tool.completed",
|
|
355
|
+
turnId,
|
|
356
|
+
callId,
|
|
357
|
+
name: tool.name,
|
|
358
|
+
ok: true,
|
|
359
|
+
output: text,
|
|
360
|
+
})
|
|
361
|
+
} catch (error) {
|
|
362
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
363
|
+
session.messages.push({ role: "tool", name: tool.name, text: message })
|
|
364
|
+
this.emit(session, {
|
|
365
|
+
type: "tool.completed",
|
|
366
|
+
turnId,
|
|
367
|
+
callId,
|
|
368
|
+
name: tool.name,
|
|
369
|
+
ok: false,
|
|
370
|
+
error: agentError("TOOL_FAILED", message),
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
throw new Error("native turn exceeded maxSteps")
|
|
375
|
+
} catch (error) {
|
|
376
|
+
if (session.closed) return
|
|
377
|
+
this.update(session, signal.aborted ? "stopped" : "failed")
|
|
378
|
+
this.emit(session, {
|
|
379
|
+
type: "session.failed",
|
|
380
|
+
error: agentError(
|
|
381
|
+
signal.aborted ? "TURN_CANCELLED" : "NATIVE_TURN_FAILED",
|
|
382
|
+
error instanceof Error ? error.message : String(error),
|
|
383
|
+
),
|
|
384
|
+
recoverable: true,
|
|
385
|
+
})
|
|
386
|
+
stream.complete()
|
|
387
|
+
session.active = undefined
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private async consumeModel(
|
|
392
|
+
raw: ReturnType<NativeModelPort["complete"]>,
|
|
393
|
+
session: NativeSession,
|
|
394
|
+
turnId: string,
|
|
395
|
+
signal: AbortSignal,
|
|
396
|
+
): Promise<NativeModelResponse> {
|
|
397
|
+
if (isAsyncIterable<NativeModelChunk>(raw)) {
|
|
398
|
+
let response: NativeModelResponse | undefined
|
|
399
|
+
for await (const chunk of raw) {
|
|
400
|
+
if (signal.aborted) throw new Error("native turn cancelled")
|
|
401
|
+
if (chunk.type === "text_delta")
|
|
402
|
+
this.emit(session, { type: "assistant.delta", turnId, text: chunk.text })
|
|
403
|
+
else response = chunk.response
|
|
404
|
+
}
|
|
405
|
+
if (response === undefined) throw new Error("native model stream ended without a response")
|
|
406
|
+
return response
|
|
407
|
+
}
|
|
408
|
+
return await raw
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private finish(session: NativeSession, stream: AgentEventStream): void {
|
|
412
|
+
this.update(session, "idle")
|
|
413
|
+
this.emit(session, { type: "session.completed", snapshot: session.snapshot })
|
|
414
|
+
stream.complete()
|
|
415
|
+
session.active = undefined
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private emit(
|
|
419
|
+
session: NativeSession,
|
|
420
|
+
payload: import("@nifrajs/agent-protocol").AgentEventPayload,
|
|
421
|
+
): void {
|
|
422
|
+
const event = Object.freeze({
|
|
423
|
+
version: 1 as const,
|
|
424
|
+
sessionId: session.id,
|
|
425
|
+
seq: session.seq++,
|
|
426
|
+
at: this.now(),
|
|
427
|
+
...payload,
|
|
428
|
+
}) as AgentEvent
|
|
429
|
+
session.snapshot = Object.freeze({
|
|
430
|
+
...session.snapshot,
|
|
431
|
+
lastSeq: event.seq,
|
|
432
|
+
updatedAt: event.at,
|
|
433
|
+
})
|
|
434
|
+
session.active?.push(event)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private update(
|
|
438
|
+
session: NativeSession,
|
|
439
|
+
status: AgentSessionSnapshot["status"],
|
|
440
|
+
turnId?: string,
|
|
441
|
+
): void {
|
|
442
|
+
const next = { ...session.snapshot, status, updatedAt: this.now(), lastSeq: session.seq }
|
|
443
|
+
session.snapshot = Object.freeze(
|
|
444
|
+
status === "running" && turnId !== undefined ? { ...next, activeTurnId: turnId } : next,
|
|
445
|
+
)
|
|
446
|
+
if (session.active !== undefined)
|
|
447
|
+
this.emit(session, { type: "session.updated", snapshot: session.snapshot })
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private requireSession(id: string): NativeSession {
|
|
451
|
+
const session = this.sessions.get(id)
|
|
452
|
+
if (session === undefined) throw new Error(`nifra backend: unknown session: ${id}`)
|
|
453
|
+
return session
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private now(): number {
|
|
457
|
+
return this.options.now?.() ?? Date.now()
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function isAsyncIterable<T>(value: unknown): value is AsyncIterable<T> {
|
|
462
|
+
return value !== null && typeof value === "object" && Symbol.asyncIterator in value
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function boundedText(value: unknown): string {
|
|
466
|
+
try {
|
|
467
|
+
const text = typeof value === "string" ? value : JSON.stringify(value)
|
|
468
|
+
return text.length > 64 * 1024 ? `${text.slice(0, 64 * 1024)}…[truncated]` : text
|
|
469
|
+
} catch {
|
|
470
|
+
return "[unserializable tool output]"
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function failedStream(error: unknown): AgentEventStream {
|
|
475
|
+
const stream = createAgentEventStream()
|
|
476
|
+
stream.fail(error)
|
|
477
|
+
return stream
|
|
478
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reference {@link ArtifactPort} implementations - the only artifact sinks the public repo ships.
|
|
3
|
+
*
|
|
4
|
+
* Neither persists durably: {@link noopArtifactPort} hashes and discards; {@link memoryArtifactPort}
|
|
5
|
+
* keeps bytes in a bounded process-local map for tests only and refuses a large budget. A durable,
|
|
6
|
+
* tenant-aware, or encrypted store is caller-owned and never lives here - the absence of a
|
|
7
|
+
* persisting sink in the public package IS the privacy guardrail.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ArtifactContext, ArtifactPort, ArtifactRef } from "@nifrajs/agent-protocol"
|
|
11
|
+
import { sha256Hex } from "./hash.ts"
|
|
12
|
+
|
|
13
|
+
function refId(digest: string, ctx: ArtifactContext): string {
|
|
14
|
+
return `${ctx.nodeId}:${ctx.kind}:${digest.slice(0, 16)}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Hashes the payload for a content-free {@link ArtifactRef}, then discards the bytes. The default
|
|
19
|
+
* port: public code sees only the digest, size, and coordinates - never the payload.
|
|
20
|
+
*/
|
|
21
|
+
export function noopArtifactPort(): ArtifactPort {
|
|
22
|
+
return {
|
|
23
|
+
async put(payload: Uint8Array, ctx: ArtifactContext): Promise<ArtifactRef> {
|
|
24
|
+
const digest = await sha256Hex(payload)
|
|
25
|
+
return {
|
|
26
|
+
id: refId(digest, ctx),
|
|
27
|
+
digest,
|
|
28
|
+
bytes: payload.length,
|
|
29
|
+
mediaType: "application/octet-stream",
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A memory port that additionally exposes stored bytes for assertions. Test-only. */
|
|
36
|
+
export interface MemoryArtifactPort extends ArtifactPort {
|
|
37
|
+
/** Return the retained bytes for an id, or undefined. Test assertions only. */
|
|
38
|
+
get(id: string): Uint8Array | undefined
|
|
39
|
+
/** Number of retained payloads. */
|
|
40
|
+
readonly count: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A disposable in-memory port for tests. Retains payloads up to a small byte budget and throws past
|
|
45
|
+
* it, refusing any load that resembles durable use. NEVER use outside tests - it has no persistence,
|
|
46
|
+
* no tenancy, and no eviction beyond the hard cap.
|
|
47
|
+
*/
|
|
48
|
+
export function memoryArtifactPort(
|
|
49
|
+
options: { readonly maxBytes?: number } = {},
|
|
50
|
+
): MemoryArtifactPort {
|
|
51
|
+
const maxBytes = options.maxBytes ?? 1_048_576
|
|
52
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
|
|
53
|
+
throw new RangeError("memoryArtifactPort: maxBytes must be a positive integer")
|
|
54
|
+
const store = new Map<string, Uint8Array>()
|
|
55
|
+
let total = 0
|
|
56
|
+
return {
|
|
57
|
+
get count() {
|
|
58
|
+
return store.size
|
|
59
|
+
},
|
|
60
|
+
get(id: string): Uint8Array | undefined {
|
|
61
|
+
return store.get(id)
|
|
62
|
+
},
|
|
63
|
+
async put(payload: Uint8Array, ctx: ArtifactContext): Promise<ArtifactRef> {
|
|
64
|
+
total += payload.length
|
|
65
|
+
if (total > maxBytes)
|
|
66
|
+
throw new Error(
|
|
67
|
+
"memoryArtifactPort: byte budget exceeded - this port is test-only and never for durable use",
|
|
68
|
+
)
|
|
69
|
+
const digest = await sha256Hex(payload)
|
|
70
|
+
const id = refId(digest, ctx)
|
|
71
|
+
store.set(id, payload.slice())
|
|
72
|
+
return { id, digest, bytes: payload.length, mediaType: "application/octet-stream" }
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The StepCatalog: the local, closure-holding registry that resolves a plan node's `step` name to a
|
|
3
|
+
* runnable handler. A {@link RunPlan} is serializable and content-free precisely because it names
|
|
4
|
+
* steps by string; the executable behavior lives here, in-process, and never in the plan.
|
|
5
|
+
*
|
|
6
|
+
* A step declares a `version` and the `capabilities` it needs. The compiler uses both to fail closed:
|
|
7
|
+
* a node that pins a drifted version, or a step that needs a capability the host did not grant, is
|
|
8
|
+
* rejected before any node runs (ORC-05, ORC-07).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ArtifactPort, RunNodeKind } from "@nifrajs/agent-protocol"
|
|
12
|
+
import type { SubagentExecutor, SubagentSpec } from "../subagents.ts"
|
|
13
|
+
|
|
14
|
+
/** Read-only view handed to a step's pure effect selector. No port, no mutation. */
|
|
15
|
+
export interface StepEffectContext {
|
|
16
|
+
readonly nodeId: string
|
|
17
|
+
readonly planDigest: string
|
|
18
|
+
readonly values: ReadonlyMap<string, unknown>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Runtime context handed to a step body. Payloads leave only through {@link StepRunContext.artifact}. */
|
|
22
|
+
export interface StepRunContext {
|
|
23
|
+
readonly nodeId: string
|
|
24
|
+
readonly planDigest: string
|
|
25
|
+
readonly signal: AbortSignal
|
|
26
|
+
/** Caller-owned artifact sink. The step routes any raw payload here to get a content-free ref. */
|
|
27
|
+
readonly artifact: ArtifactPort
|
|
28
|
+
readonly values: ReadonlyMap<string, unknown>
|
|
29
|
+
set(name: string, value: unknown): void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One catalog handler. `kind` must match the plan node's kind at compile time.
|
|
34
|
+
*
|
|
35
|
+
* - Leaf kinds `task`/`verify`/`approve`/`checkpoint`/`handoff` implement {@link CatalogStep.run}.
|
|
36
|
+
* - `subagent` implements {@link CatalogStep.spec} and supplies an {@link CatalogStep.executor};
|
|
37
|
+
* the compiler drives it through a {@link BoundedSubagentRunner} (never a private copy).
|
|
38
|
+
* - `branch` implements {@link CatalogStep.when}, a content-free predicate.
|
|
39
|
+
*
|
|
40
|
+
* `selectEffect`, when present, marks the step idempotent and projects its identity-bearing input
|
|
41
|
+
* into content-free bytes for `deriveNodeEffectKey`. It MUST be pure and read-only.
|
|
42
|
+
*/
|
|
43
|
+
export interface CatalogStep {
|
|
44
|
+
readonly kind: RunNodeKind
|
|
45
|
+
/** Positive step version. Defaults to 1 when omitted. A node may pin an exact version. */
|
|
46
|
+
readonly version?: number
|
|
47
|
+
/** Capabilities this step needs. The host's allow-list is the ceiling; a superset fails closed. */
|
|
48
|
+
readonly capabilities?: readonly string[]
|
|
49
|
+
run?(context: StepRunContext): unknown | PromiseLike<unknown>
|
|
50
|
+
selectEffect?(context: StepEffectContext): Uint8Array
|
|
51
|
+
/** `subagent` kind: the bounded child executor and the content-free spec projection. */
|
|
52
|
+
readonly executor?: SubagentExecutor
|
|
53
|
+
spec?(context: StepRunContext): SubagentSpec | PromiseLike<SubagentSpec>
|
|
54
|
+
/** `branch` kind: a content-free predicate selecting `then` or `otherwise`. */
|
|
55
|
+
when?(context: StepRunContext): boolean | PromiseLike<boolean>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Thrown on a catalog construction fault: a key collision across merged catalogs. */
|
|
59
|
+
export class CatalogError extends Error {
|
|
60
|
+
constructor(readonly reason: string) {
|
|
61
|
+
super(`step catalog: ${reason}`)
|
|
62
|
+
this.name = "CatalogError"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The effective version of a step (defaults to 1). */
|
|
67
|
+
export function stepVersion(step: CatalogStep): number {
|
|
68
|
+
return step.version ?? 1
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Resolves step names to handlers. Immutable after construction; lookup is deterministic. */
|
|
72
|
+
export interface StepCatalog {
|
|
73
|
+
get(step: string): CatalogStep | undefined
|
|
74
|
+
has(step: string): boolean
|
|
75
|
+
/** Sorted for deterministic enumeration. */
|
|
76
|
+
readonly keys: readonly string[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function fromMap(map: ReadonlyMap<string, CatalogStep>): StepCatalog {
|
|
80
|
+
return {
|
|
81
|
+
get: (step) => map.get(step),
|
|
82
|
+
has: (step) => map.has(step),
|
|
83
|
+
keys: Object.freeze([...map.keys()].sort()),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Build a StepCatalog from a name -> handler map. */
|
|
88
|
+
export function createStepCatalog(entries: Readonly<Record<string, CatalogStep>>): StepCatalog {
|
|
89
|
+
return fromMap(new Map(Object.entries(entries)))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Merge catalogs into one. Throws {@link CatalogError} on any duplicate step key (collision). */
|
|
93
|
+
export function mergeStepCatalogs(...catalogs: readonly StepCatalog[]): StepCatalog {
|
|
94
|
+
const map = new Map<string, CatalogStep>()
|
|
95
|
+
for (const catalog of catalogs) {
|
|
96
|
+
for (const key of catalog.keys) {
|
|
97
|
+
if (map.has(key)) throw new CatalogError(`duplicate step key '${key}'`)
|
|
98
|
+
map.set(key, catalog.get(key) as CatalogStep)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return fromMap(map)
|
|
102
|
+
}
|