@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.
Files changed (211) hide show
  1. package/EXTENSIONS.md +25 -0
  2. package/PERFORMANCE.md +19 -0
  3. package/README.md +69 -0
  4. package/REGISTRY.md +15 -0
  5. package/SECURITY.md +32 -0
  6. package/dist/approvals.d.ts +70 -0
  7. package/dist/approvals.d.ts.map +1 -0
  8. package/dist/approvals.js +174 -0
  9. package/dist/approvals.js.map +1 -0
  10. package/dist/capabilities.d.ts +20 -0
  11. package/dist/capabilities.d.ts.map +1 -0
  12. package/dist/capabilities.js +49 -0
  13. package/dist/capabilities.js.map +1 -0
  14. package/dist/cli.d.ts +24 -0
  15. package/dist/cli.d.ts.map +1 -0
  16. package/dist/cli.js +380 -0
  17. package/dist/cli.js.map +1 -0
  18. package/dist/context.d.ts +17 -0
  19. package/dist/context.d.ts.map +1 -0
  20. package/dist/context.js +66 -0
  21. package/dist/context.js.map +1 -0
  22. package/dist/deployment-adapters.d.ts +40 -0
  23. package/dist/deployment-adapters.d.ts.map +1 -0
  24. package/dist/deployment-adapters.js +147 -0
  25. package/dist/deployment-adapters.js.map +1 -0
  26. package/dist/diff.d.ts +17 -0
  27. package/dist/diff.d.ts.map +1 -0
  28. package/dist/diff.js +61 -0
  29. package/dist/diff.js.map +1 -0
  30. package/dist/errors.d.ts +9 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/errors.js +35 -0
  33. package/dist/errors.js.map +1 -0
  34. package/dist/extensions.d.ts +104 -0
  35. package/dist/extensions.d.ts.map +1 -0
  36. package/dist/extensions.js +284 -0
  37. package/dist/extensions.js.map +1 -0
  38. package/dist/handoffs.d.ts +97 -0
  39. package/dist/handoffs.d.ts.map +1 -0
  40. package/dist/handoffs.js +199 -0
  41. package/dist/handoffs.js.map +1 -0
  42. package/dist/healing.d.ts +56 -0
  43. package/dist/healing.d.ts.map +1 -0
  44. package/dist/healing.js +54 -0
  45. package/dist/healing.js.map +1 -0
  46. package/dist/host.d.ts +61 -0
  47. package/dist/host.d.ts.map +1 -0
  48. package/dist/host.js +369 -0
  49. package/dist/host.js.map +1 -0
  50. package/dist/index.d.ts +26 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +26 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/isolated-worker.d.ts +2 -0
  55. package/dist/isolated-worker.d.ts.map +1 -0
  56. package/dist/isolated-worker.js +155 -0
  57. package/dist/isolated-worker.js.map +1 -0
  58. package/dist/isolated.d.ts +52 -0
  59. package/dist/isolated.d.ts.map +1 -0
  60. package/dist/isolated.js +293 -0
  61. package/dist/isolated.js.map +1 -0
  62. package/dist/native.d.ts +92 -0
  63. package/dist/native.d.ts.map +1 -0
  64. package/dist/native.js +339 -0
  65. package/dist/native.js.map +1 -0
  66. package/dist/orchestration/artifact-port.d.ts +30 -0
  67. package/dist/orchestration/artifact-port.d.ts.map +1 -0
  68. package/dist/orchestration/artifact-port.js +59 -0
  69. package/dist/orchestration/artifact-port.js.map +1 -0
  70. package/dist/orchestration/catalog.d.ts +71 -0
  71. package/dist/orchestration/catalog.d.ts.map +1 -0
  72. package/dist/orchestration/catalog.js +46 -0
  73. package/dist/orchestration/catalog.js.map +1 -0
  74. package/dist/orchestration/compile.d.ts +59 -0
  75. package/dist/orchestration/compile.d.ts.map +1 -0
  76. package/dist/orchestration/compile.js +268 -0
  77. package/dist/orchestration/compile.js.map +1 -0
  78. package/dist/orchestration/dispatch.d.ts +115 -0
  79. package/dist/orchestration/dispatch.d.ts.map +1 -0
  80. package/dist/orchestration/dispatch.js +220 -0
  81. package/dist/orchestration/dispatch.js.map +1 -0
  82. package/dist/orchestration/durable-jobs.d.ts +35 -0
  83. package/dist/orchestration/durable-jobs.d.ts.map +1 -0
  84. package/dist/orchestration/durable-jobs.js +273 -0
  85. package/dist/orchestration/durable-jobs.js.map +1 -0
  86. package/dist/orchestration/effect-key.d.ts +23 -0
  87. package/dist/orchestration/effect-key.d.ts.map +1 -0
  88. package/dist/orchestration/effect-key.js +24 -0
  89. package/dist/orchestration/effect-key.js.map +1 -0
  90. package/dist/orchestration/evidence-store.d.ts +75 -0
  91. package/dist/orchestration/evidence-store.d.ts.map +1 -0
  92. package/dist/orchestration/evidence-store.js +183 -0
  93. package/dist/orchestration/evidence-store.js.map +1 -0
  94. package/dist/orchestration/hash.d.ts +12 -0
  95. package/dist/orchestration/hash.d.ts.map +1 -0
  96. package/dist/orchestration/hash.js +43 -0
  97. package/dist/orchestration/hash.js.map +1 -0
  98. package/dist/orchestration/host.d.ts +121 -0
  99. package/dist/orchestration/host.d.ts.map +1 -0
  100. package/dist/orchestration/host.js +334 -0
  101. package/dist/orchestration/host.js.map +1 -0
  102. package/dist/orchestration/index.d.ts +20 -0
  103. package/dist/orchestration/index.d.ts.map +1 -0
  104. package/dist/orchestration/index.js +19 -0
  105. package/dist/orchestration/index.js.map +1 -0
  106. package/dist/orchestration/policy.d.ts +63 -0
  107. package/dist/orchestration/policy.d.ts.map +1 -0
  108. package/dist/orchestration/policy.js +80 -0
  109. package/dist/orchestration/policy.js.map +1 -0
  110. package/dist/orchestration/recovery.d.ts +87 -0
  111. package/dist/orchestration/recovery.d.ts.map +1 -0
  112. package/dist/orchestration/recovery.js +197 -0
  113. package/dist/orchestration/recovery.js.map +1 -0
  114. package/dist/orchestration/tracer.d.ts +31 -0
  115. package/dist/orchestration/tracer.d.ts.map +1 -0
  116. package/dist/orchestration/tracer.js +141 -0
  117. package/dist/orchestration/tracer.js.map +1 -0
  118. package/dist/plans.d.ts +35 -0
  119. package/dist/plans.d.ts.map +1 -0
  120. package/dist/plans.js +63 -0
  121. package/dist/plans.js.map +1 -0
  122. package/dist/presets.d.ts +10 -0
  123. package/dist/presets.d.ts.map +1 -0
  124. package/dist/presets.js +70 -0
  125. package/dist/presets.js.map +1 -0
  126. package/dist/process.d.ts +6 -0
  127. package/dist/process.d.ts.map +1 -0
  128. package/dist/process.js +42 -0
  129. package/dist/process.js.map +1 -0
  130. package/dist/registry.d.ts +37 -0
  131. package/dist/registry.d.ts.map +1 -0
  132. package/dist/registry.js +51 -0
  133. package/dist/registry.js.map +1 -0
  134. package/dist/replay.d.ts +24 -0
  135. package/dist/replay.d.ts.map +1 -0
  136. package/dist/replay.js +154 -0
  137. package/dist/replay.js.map +1 -0
  138. package/dist/rpc.d.ts +51 -0
  139. package/dist/rpc.d.ts.map +1 -0
  140. package/dist/rpc.js +412 -0
  141. package/dist/rpc.js.map +1 -0
  142. package/dist/session-migration.d.ts +59 -0
  143. package/dist/session-migration.d.ts.map +1 -0
  144. package/dist/session-migration.js +315 -0
  145. package/dist/session-migration.js.map +1 -0
  146. package/dist/sessions.d.ts +74 -0
  147. package/dist/sessions.d.ts.map +1 -0
  148. package/dist/sessions.js +222 -0
  149. package/dist/sessions.js.map +1 -0
  150. package/dist/subagents.d.ts +51 -0
  151. package/dist/subagents.d.ts.map +1 -0
  152. package/dist/subagents.js +141 -0
  153. package/dist/subagents.js.map +1 -0
  154. package/dist/tools.d.ts +13 -0
  155. package/dist/tools.d.ts.map +1 -0
  156. package/dist/tools.js +37 -0
  157. package/dist/tools.js.map +1 -0
  158. package/dist/ui.d.ts +42 -0
  159. package/dist/ui.d.ts.map +1 -0
  160. package/dist/ui.js +93 -0
  161. package/dist/ui.js.map +1 -0
  162. package/dist/verification.d.ts +34 -0
  163. package/dist/verification.d.ts.map +1 -0
  164. package/dist/verification.js +91 -0
  165. package/dist/verification.js.map +1 -0
  166. package/dist/workflows.d.ts +98 -0
  167. package/dist/workflows.d.ts.map +1 -0
  168. package/dist/workflows.js +174 -0
  169. package/dist/workflows.js.map +1 -0
  170. package/package.json +84 -0
  171. package/src/approvals.ts +249 -0
  172. package/src/capabilities.ts +74 -0
  173. package/src/cli.ts +405 -0
  174. package/src/context.ts +81 -0
  175. package/src/deployment-adapters.ts +181 -0
  176. package/src/diff.ts +80 -0
  177. package/src/errors.ts +46 -0
  178. package/src/extensions.ts +402 -0
  179. package/src/handoffs.ts +299 -0
  180. package/src/healing.ts +93 -0
  181. package/src/host.ts +465 -0
  182. package/src/index.ts +165 -0
  183. package/src/isolated-worker.ts +177 -0
  184. package/src/isolated.ts +344 -0
  185. package/src/native.ts +478 -0
  186. package/src/orchestration/artifact-port.ts +75 -0
  187. package/src/orchestration/catalog.ts +102 -0
  188. package/src/orchestration/compile.ts +373 -0
  189. package/src/orchestration/dispatch.ts +383 -0
  190. package/src/orchestration/durable-jobs.ts +374 -0
  191. package/src/orchestration/effect-key.ts +35 -0
  192. package/src/orchestration/evidence-store.ts +233 -0
  193. package/src/orchestration/hash.ts +43 -0
  194. package/src/orchestration/host.ts +445 -0
  195. package/src/orchestration/index.ts +131 -0
  196. package/src/orchestration/policy.ts +116 -0
  197. package/src/orchestration/recovery.ts +271 -0
  198. package/src/orchestration/tracer.ts +177 -0
  199. package/src/plans.ts +105 -0
  200. package/src/presets.ts +90 -0
  201. package/src/process.ts +47 -0
  202. package/src/registry.ts +80 -0
  203. package/src/replay.ts +190 -0
  204. package/src/rpc.ts +598 -0
  205. package/src/session-migration.ts +401 -0
  206. package/src/sessions.ts +303 -0
  207. package/src/subagents.ts +210 -0
  208. package/src/tools.ts +52 -0
  209. package/src/ui.ts +137 -0
  210. package/src/verification.ts +133 -0
  211. 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
+ }