@jini-ai/daemon 0.6.19 → 0.6.22

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/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## 0.6.22
2
+
3
+ - Child stdout can be framed and sanitized line by line before parsing. A timed-out or cancelled delegated tool can report host-loaded recovery facts through `recoverInterruptedTool`, with no retry.
4
+
1
5
  ## 0.6.19
2
6
 
3
7
  - `DelegatedToolsHttpDeps.toolRegistry` takes a list-only registry view (`Pick<ToolRegistry, 'list'>`): the route only reads descriptors, and hosts pass descriptor-only facades.
@@ -11,7 +11,7 @@ import type { ChildDrivenStreamFormat, TerminateChildTreeDeps, AgentCleanupFailu
11
11
  /** Default sink when a host does not supply `onCleanupFailure`: still observable, never silent. Redacted per SEC-007 (a spawn/permission error can embed paths/host detail). */
12
12
  export declare function defaultCleanupFailureSink(context: AgentCleanupFailureContext): void;
13
13
  /**
14
- * Default ceiling on the `'until-close'` stdout accumulator (see `RuntimeStdoutPolicy` in
14
+ * Default ceiling on the `'until-close'` or per-line stdout accumulator (see `RuntimeStdoutPolicy` in
15
15
  * `@jini-ai/agent-runtime`), in bytes of received UTF-8.
16
16
  *
17
17
  * A buffered def holds its child's entire stdout in one in-memory string until the process closes,
@@ -20,8 +20,8 @@ export declare function defaultCleanupFailureSink(context: AgentCleanupFailureCo
20
20
  * nothing obliges it to ever close or to stop emitting. Without a ceiling one run could exhaust the
21
21
  * daemon's heap and take every unrelated run in the process down with it.
22
22
  *
23
- * 8 MiB is chosen to sit far above any real buffered-agent transcript (antigravity's print-mode
24
- * output — the only `'until-close'` def — is a few KiB of auth prompt and result text) while staying
23
+ * 8 MiB was chosen to sit far above the original buffered print-mode auth prompt and result text
24
+ * (a few KiB) while staying
25
25
  * small enough that a hostile child cannot meaningfully pressure the heap. A host that genuinely
26
26
  * needs more passes `CreateAgentExecutorOptions.bufferedStdoutMaxBytes`.
27
27
  */
@@ -101,13 +101,15 @@ interface WireChildLifecycleContext extends TerminateChildTreeDeps {
101
101
  *
102
102
  * *When* those chunks leave is the def's call, via `def.stdoutPolicy`:
103
103
  *
104
- * - `'live'` (the default, and every def but antigravity) — emit per
104
+ * - `'live'` (the default) — emit per
105
105
  * chunk, as it arrives.
106
106
  * - `'until-close'` — accumulate, and emit the whole thing exactly once
107
107
  * from the `close` handler, after `def.stdoutPolicy.sanitize`. For an
108
108
  * adapter that can print a secret to stdout and still exit 0, no
109
109
  * per-chunk decision is safe: the pattern to redact can straddle two
110
110
  * `'data'` events.
111
+ * - `'line'` — sanitize each bounded complete wire line before both raw delivery and parsing;
112
+ * retain only its partial tail. Secrets split across chunks stay private without waiting for exit.
111
113
  *
112
114
  * The buffered path holds back the raw `'stdout'` echo too, not just the
113
115
  * `'agent'`/`text_delta`, and sanitizes both. Emitting an unsanitized raw
@@ -1,6 +1,7 @@
1
1
  import { createToolResultDiagnosticProjection, projectDiagnosticFrame } from '../tool-result-diagnostics.js';
2
2
  import { appendSerialTask } from "@jini-ai/core/primitives";
3
3
  import { createLiveSessionState } from './live-session-state.js';
4
+ import { createSanitizedLineStream } from './sanitized-line-stream.js';
4
5
  import { acceptsMidRunUserMessages, createMessageRelay } from './message-relay.js';
5
6
  import { randomUUID } from 'node:crypto';
6
7
  import { createModelReceiptTracker } from './model-receipts.js';
@@ -186,7 +187,7 @@ function receivedJournalEntry(channel, content) {
186
187
  return { content, provenance: { source: 'agent', channel }, trust: 'untrusted' };
187
188
  }
188
189
  /**
189
- * Default ceiling on the `'until-close'` stdout accumulator (see `RuntimeStdoutPolicy` in
190
+ * Default ceiling on the `'until-close'` or per-line stdout accumulator (see `RuntimeStdoutPolicy` in
190
191
  * `@jini-ai/agent-runtime`), in bytes of received UTF-8.
191
192
  *
192
193
  * A buffered def holds its child's entire stdout in one in-memory string until the process closes,
@@ -195,8 +196,8 @@ function receivedJournalEntry(channel, content) {
195
196
  * nothing obliges it to ever close or to stop emitting. Without a ceiling one run could exhaust the
196
197
  * daemon's heap and take every unrelated run in the process down with it.
197
198
  *
198
- * 8 MiB is chosen to sit far above any real buffered-agent transcript (antigravity's print-mode
199
- * output — the only `'until-close'` def — is a few KiB of auth prompt and result text) while staying
199
+ * 8 MiB was chosen to sit far above the original buffered print-mode auth prompt and result text
200
+ * (a few KiB) while staying
200
201
  * small enough that a hostile child cannot meaningfully pressure the heap. A host that genuinely
201
202
  * needs more passes `CreateAgentExecutorOptions.bufferedStdoutMaxBytes`.
202
203
  */
@@ -283,13 +284,15 @@ function createStdoutDiagnostics({ ctx, enqueueEmit }, _options = {}) {
283
284
  *
284
285
  * *When* those chunks leave is the def's call, via `def.stdoutPolicy`:
285
286
  *
286
- * - `'live'` (the default, and every def but antigravity) — emit per
287
+ * - `'live'` (the default) — emit per
287
288
  * chunk, as it arrives.
288
289
  * - `'until-close'` — accumulate, and emit the whole thing exactly once
289
290
  * from the `close` handler, after `def.stdoutPolicy.sanitize`. For an
290
291
  * adapter that can print a secret to stdout and still exit 0, no
291
292
  * per-chunk decision is safe: the pattern to redact can straddle two
292
293
  * `'data'` events.
294
+ * - `'line'` — sanitize each bounded complete wire line before both raw delivery and parsing;
295
+ * retain only its partial tail. Secrets split across chunks stay private without waiting for exit.
293
296
  *
294
297
  * The buffered path holds back the raw `'stdout'` echo too, not just the
295
298
  * `'agent'`/`text_delta`, and sanitizes both. Emitting an unsanitized raw
@@ -364,12 +367,25 @@ export function wireChildLifecycle(ctx) {
364
367
  let bridgeChecked = !ctx.expectsJiniBridge;
365
368
  let bridgeUnavailable = false;
366
369
  // `def.stdoutPolicy` read once, up front, so the per-chunk handler below is a single boolean
367
- // test rather than a repeated union narrowing. `undefined` (every def but antigravity) means
370
+ // test rather than a repeated union narrowing. `undefined` means
368
371
  // live — see this function's own doc.
369
372
  const stdoutPolicy = def.stdoutPolicy;
370
373
  const bufferStdoutUntilClose = stdoutPolicy?.buffering === 'until-close';
371
374
  const sanitizeBufferedStdout = stdoutPolicy?.buffering === 'until-close' ? stdoutPolicy.sanitize : undefined;
372
- // Accumulator for the `'until-close'` path. Stays `''` for every live def, and the flush below
375
+ const lineStdout = stdoutPolicy?.buffering === 'line' ? createSanitizedLineStream({
376
+ maxBytes: ctx.bufferedStdoutMaxBytes, sanitize: stdoutPolicy.sanitize,
377
+ emit: ({ text }) => {
378
+ stdoutDiagnostics.feed({ text });
379
+ if (streamFormat === 'plain')
380
+ enqueueEmit(() => lifecycle.emit({ runId, input: { event: 'agent', data: { type: 'text_delta', delta: text } } }));
381
+ else
382
+ streamHandler?.feed({ chunk: text });
383
+ },
384
+ onOverflow: ({ bytes }) => enqueueEmit(() => lifecycle.emit({ runId, input: { event: 'error', data: {
385
+ message: bufferedStdoutTruncationNotice(bytes, ctx.bufferedStdoutMaxBytes),
386
+ } } })),
387
+ }) : undefined;
388
+ // Accumulator for the `'until-close'` path. Stays `''` for live and line policies, and the flush below
373
389
  // is then a no-op that emits nothing. Bounded by `ctx.bufferedStdoutMaxBytes` — see
374
390
  // {@link DEFAULT_BUFFERED_STDOUT_MAX_BYTES} for why an unbounded accumulator was a
375
391
  // denial-of-service surface rather than merely untidy.
@@ -647,6 +663,10 @@ export function wireChildLifecycle(ctx) {
647
663
  child.stdout?.on('data', (chunk) => {
648
664
  const text = chunk.toString('utf8');
649
665
  stdoutDiagnostics.record({ chunk, text });
666
+ if (lineStdout) {
667
+ lineStdout.feed({ chunk: text });
668
+ return;
669
+ }
650
670
  if (streamFormat === 'plain' && text.length > 0)
651
671
  userVisibleOutputSeen = true;
652
672
  if (bufferStdoutUntilClose) {
@@ -716,6 +736,7 @@ export function wireChildLifecycle(ctx) {
716
736
  controlScanBuffer = '';
717
737
  }
718
738
  flushBufferedStdout();
739
+ lineStdout?.flush({});
719
740
  streamHandler?.flush();
720
741
  session.endSession();
721
742
  stdoutDiagnostics.flush();
@@ -0,0 +1,24 @@
1
+ /** Frame and sanitize stdout before either parser or raw-event delivery.
2
+ * @param required - Host byte cap, pure sanitizer and delivery/error sinks.
3
+ * @returns A per-process stream; flush delivers a final unterminated line once.
4
+ * @complexity O(n) time for n wire characters, O(maxBytes) retained text plus incoming chunk slices.
5
+ * @example const stream = createSanitizedLineStream({ maxBytes: 4096, sanitize, emit, onOverflow });
6
+ */
7
+ export declare function createSanitizedLineStream({ maxBytes, sanitize, emit, onOverflow }: {
8
+ maxBytes: number;
9
+ sanitize: (required: {
10
+ fullText: string;
11
+ }) => string;
12
+ emit: (required: {
13
+ text: string;
14
+ }, optional?: Record<string, never>) => void;
15
+ onOverflow: (required: {
16
+ bytes: number;
17
+ }, optional?: Record<string, never>) => void;
18
+ }, _options?: Record<string, never>): {
19
+ feed(required: {
20
+ chunk: string;
21
+ }, optional?: Record<string, never>): void;
22
+ flush(required: Record<string, never>, optional?: Record<string, never>): void;
23
+ };
24
+ //# sourceMappingURL=sanitized-line-stream.d.ts.map
@@ -0,0 +1,45 @@
1
+ /** Frame and sanitize stdout before either parser or raw-event delivery.
2
+ * @param required - Host byte cap, pure sanitizer and delivery/error sinks.
3
+ * @returns A per-process stream; flush delivers a final unterminated line once.
4
+ * @complexity O(n) time for n wire characters, O(maxBytes) retained text plus incoming chunk slices.
5
+ * @example const stream = createSanitizedLineStream({ maxBytes: 4096, sanitize, emit, onOverflow });
6
+ */
7
+ export function createSanitizedLineStream({ maxBytes, sanitize, emit, onOverflow }, _options = {}) {
8
+ let parts = [];
9
+ let bytes = 0;
10
+ let overflow = false;
11
+ /** One line's fragments retain at most maxBytes; an oversized line is discarded through its end. */
12
+ function append(text) {
13
+ if (!text)
14
+ return;
15
+ bytes += Buffer.byteLength(text, 'utf8');
16
+ if (bytes > maxBytes) {
17
+ parts = [];
18
+ overflow = true;
19
+ }
20
+ if (!overflow)
21
+ parts.push(text);
22
+ }
23
+ /** Delivery happens once per completed line, or once for the final unterminated tail. */
24
+ function flush(newline) {
25
+ if (overflow)
26
+ onOverflow({ bytes });
27
+ else if (bytes > 0 || newline)
28
+ emit({ text: sanitize({ fullText: parts.join('') + newline }) });
29
+ parts = [];
30
+ bytes = 0;
31
+ overflow = false;
32
+ }
33
+ return {
34
+ feed({ chunk }, _optional = {}) {
35
+ const lines = chunk.split('\n');
36
+ lines.forEach((line, index) => {
37
+ append(line);
38
+ if (index < lines.length - 1)
39
+ flush('\n');
40
+ });
41
+ },
42
+ flush(_required, _optional = {}) { flush(''); },
43
+ };
44
+ }
45
+ //# sourceMappingURL=sanitized-line-stream.js.map
@@ -37,6 +37,8 @@ export interface DelegatedToolExecuteResponse {
37
37
  */
38
38
  export interface DelegatedToolsInternalErrorContext {
39
39
  readonly source: 'delegated-tool-execute' | 'resolve-principal';
40
+ /** Optional host-loaded recovery facts; the host describer must redact these as usual. */
41
+ readonly recoveryMessage?: string;
40
42
  readonly runId: string;
41
43
  readonly toolId: string;
42
44
  readonly correlationId: string;
@@ -50,6 +52,16 @@ export interface DelegatedToolsInternalErrorContext {
50
52
  readonly status?: 'timed-out' | 'cancelled' | 'failed';
51
53
  }
52
54
  export interface DelegatedToolsHttpDeps {
55
+ /** One bounded read after an interrupted call, for hosts whose jobs live in another process.
56
+ * Must authorize with the supplied principal, never execute a write/retry, and impose its own
57
+ * deadline. A throw is contained; absent facts remain unknown. No calls on the success path.
58
+ */
59
+ readonly recoverInterruptedTool?: (required: {
60
+ principal: Principal;
61
+ runId: string;
62
+ toolId: string;
63
+ result: ToolExecutionResult;
64
+ }) => Promise<string | undefined>;
53
65
  /** The same negotiated digest store supplied to the run lifecycle and agent executor. */
54
66
  readonly delegatedResultCorrelation?: DelegatedResultCorrelation;
55
67
  readonly lifecycle: RunLifecycle;
@@ -130,7 +130,7 @@ function parseDelegatedToolExecute(input) {
130
130
  * vouches for this exact result via `deps.isModelSafeToolFailure` (it has already redacted the text
131
131
  * itself), in which case it is a `422 TOOL_EXECUTION_FAILED` carrying the host's text verbatim.
132
132
  */
133
- function toolExecutionResultToApiResult(deps, { runId, toolId, result }) {
133
+ function toolExecutionResultToApiResult(deps, { runId, toolId, result, recoveryMessage }) {
134
134
  switch (result.status) {
135
135
  case 'completed': {
136
136
  const outcome = batchOutcomeFor({ output: result.output });
@@ -143,7 +143,7 @@ function toolExecutionResultToApiResult(deps, { runId, toolId, result }) {
143
143
  return err({ error: createApiError({ code: 'TOOL_OPERATION_DENIED', message: 'this operation was denied during confirmation' }) });
144
144
  case 'timed-out':
145
145
  case 'cancelled':
146
- return err({ error: reportInternalError(deps, { source: 'delegated-tool-execute', error: result.status, runId, toolId, status: result.status }) });
146
+ return err({ error: reportInternalError(deps, { source: 'delegated-tool-execute', error: result.status, runId, toolId, ...(recoveryMessage ? { recoveryMessage } : {}), status: result.status }) });
147
147
  case 'failed':
148
148
  return err({ error: failedResultToApiError(deps, { runId, toolId, result }) });
149
149
  default: {
@@ -163,6 +163,21 @@ function failedResultToApiError(deps, { runId, toolId, result }) {
163
163
  }
164
164
  return reportInternalError(deps, { source: 'delegated-tool-execute', error: result.error ?? result.status, runId, toolId, status: 'failed' });
165
165
  }
166
+ /** Read host-owned recovery facts once after interruption. The host supplies the deadline and
167
+ * permission-checked read; a failed read never replaces or retries the original operation.
168
+ * @complexity One optional bounded read; no extra work on successful executions.
169
+ */
170
+ async function toolExecutionResultWithRecovery(deps, required) {
171
+ const { principal, runId, toolId, result } = required;
172
+ let recoveryMessage;
173
+ if (result.status === 'timed-out' || result.status === 'cancelled') {
174
+ try {
175
+ recoveryMessage = await deps.recoverInterruptedTool?.({ principal, runId, toolId, result });
176
+ }
177
+ catch { /* Recovery cannot change the interrupted write or expose its own failure. */ }
178
+ }
179
+ return toolExecutionResultToApiResult(deps, { runId, toolId, result, ...(recoveryMessage ? { recoveryMessage } : {}) });
180
+ }
166
181
  /**
167
182
  * `POST /api/delegated-tool-calls` — executes one delegated tool call against an already-started
168
183
  * run. Maps `ToolExecutionResult.status` to the HTTP response via `toolExecutionResultToApiResult`
@@ -224,7 +239,7 @@ export const delegatedToolExecuteRoute = defineJsonRoute({
224
239
  input: input.input,
225
240
  ...(signal !== undefined ? { signal } : {}),
226
241
  });
227
- return toolExecutionResultToApiResult(deps, { runId: input.runId, toolId: input.toolId, result });
242
+ return toolExecutionResultWithRecovery(deps, { principal, runId: input.runId, toolId: input.toolId, result });
228
243
  }
229
244
  catch (error) {
230
245
  if (error instanceof ToolInputError) {
@@ -77,6 +77,7 @@ export declare function isRedactedToolFailure({ result }: {
77
77
  * compiles against a published http-kit that predates `status`.
78
78
  */
79
79
  export interface DelegatedInternalErrorContext {
80
+ readonly recoveryMessage?: string;
80
81
  readonly source: string;
81
82
  readonly runId: string;
82
83
  readonly toolId: string;
@@ -97,7 +97,10 @@ function delegatedInternalErrorReason(context) {
97
97
  * @complexity O(reason length) for the redaction pass.
98
98
  */
99
99
  export function describeDelegatedInternalError({ context, ...deps }, _optional = {}) {
100
- const { text, redactions } = deps.redactSecretShapes({ text: delegatedInternalErrorReason(context) });
100
+ let reason = delegatedInternalErrorReason(context);
101
+ if ((context.status === "timed-out" || context.status === "cancelled") && context.recoveryMessage)
102
+ reason = context.recoveryMessage;
103
+ const { text, redactions } = deps.redactSecretShapes({ text: reason });
101
104
  const errorId = deps.mintErrorId();
102
105
  safeOnFailure(deps.onFailure, {
103
106
  errorId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/daemon",
3
- "version": "0.6.19",
3
+ "version": "0.6.22",
4
4
  "description": "The stateful agent daemon runtime: run lifecycle, durable event-log port, the tool-execution boundary, agent executor, terminal sessions, and routines.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -153,22 +153,22 @@
153
153
  "express": "^4.21.0",
154
154
  "@types/express": "^4.17.21",
155
155
  "@jini-ai/db": "0.6.19",
156
- "@jini-ai/agent-runtime": "^0.6.19",
157
- "@jini-ai/chat": "0.6.19",
158
- "@jini-ai/core": "^0.6.19",
159
- "@jini-ai/platform": "^0.6.19",
160
- "@jini-ai/http-kit": "0.6.19",
161
- "@jini-ai/protocol": "^0.6.19"
156
+ "@jini-ai/agent-runtime": "^0.6.22",
157
+ "@jini-ai/chat": "0.6.22",
158
+ "@jini-ai/core": "^0.6.22",
159
+ "@jini-ai/protocol": "^0.6.19",
160
+ "@jini-ai/platform": "^0.6.22",
161
+ "@jini-ai/http-kit": "0.6.22"
162
162
  },
163
163
  "dependencies": {
164
164
  "smol-toml": "1.9.1",
165
165
  "kysely": "^0.29.6",
166
- "@jini-ai/core": "^0.6.19",
166
+ "@jini-ai/core": "^0.6.22",
167
167
  "@jini-ai/protocol": "^0.6.19",
168
- "@jini-ai/agent-runtime": "^0.6.19",
169
168
  "@jini-ai/db": "^0.6.19",
170
- "@jini-ai/platform": "^0.6.19",
171
- "@jini-ai/http-kit": "^0.6.19"
169
+ "@jini-ai/agent-runtime": "^0.6.22",
170
+ "@jini-ai/platform": "^0.6.22",
171
+ "@jini-ai/http-kit": "^0.6.22"
172
172
  },
173
173
  "peerDependencies": {
174
174
  "node-pty": "^1.1.0",