@jini-ai/daemon 0.6.9 → 0.6.10
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 +144 -0
- package/dist/agent-executor/contracts.d.ts +6 -0
- package/dist/agent-executor/event-translation.js +2 -0
- package/dist/agent-executor/events.d.ts +4 -0
- package/dist/agent-executor/events.js +58 -13
- package/dist/agent-executor/launch.d.ts +1 -1
- package/dist/agent-executor/launch.js +9 -0
- package/dist/delegated-result-correlation.d.ts +26 -0
- package/dist/delegated-result-correlation.js +68 -0
- package/dist/delegated-tool-bridge.d.ts +5 -13
- package/dist/delegated-tool-bridge.js +19 -58
- package/dist/http/delegated-tools.d.ts +3 -0
- package/dist/http/delegated-tools.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/run-lifecycle.d.ts +20 -1
- package/dist/run-lifecycle.js +2 -16
- package/dist/tool-result-diagnostics.d.ts +27 -0
- package/dist/tool-result-diagnostics.js +77 -0
- package/dist/tool-result-media.d.ts +27 -14
- package/dist/tool-result-media.js +51 -38
- package/package.json +14 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,147 @@
|
|
|
1
|
+
## 0.6.10
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 4f52784: Stop advertising agents `AgentExecutor` cannot run.
|
|
6
|
+
|
|
7
|
+
The built-in `agents` feature scanned every runtime definition, but `AgentExecutor` rejects some of
|
|
8
|
+
them (`antigravity`, whose deferral is documented in that module). A consumer could therefore offer a
|
|
9
|
+
user an agent that failed the instant it was selected, with the mismatch visible nowhere.
|
|
10
|
+
|
|
11
|
+
- `@jini-ai/daemon` exports `isAgentExecutorSupported(def)` and `assessAgentExecutorCompatibility(def)`.
|
|
12
|
+
`run()` now consumes the latter instead of re-checking the conditions itself, so the discovery-time
|
|
13
|
+
answer and the run-time guards cannot drift apart. Error messages are unchanged.
|
|
14
|
+
- `@jini-ai/server`'s `agents` feature applies the predicate by default, and exports
|
|
15
|
+
`isExecutableDetectedAgent` for hosts that want it directly.
|
|
16
|
+
|
|
17
|
+
`antigravity`'s definition remains in `@jini-ai/agent-runtime` — the predicate is advisory, and a
|
|
18
|
+
non-`AgentExecutor` launch path can ignore it. An id with no registered def is kept rather than
|
|
19
|
+
dropped, so a host supplying its own `detector` is unaffected.
|
|
20
|
+
|
|
21
|
+
Note for anyone implementing something similar: the predicate deliberately takes the full
|
|
22
|
+
`RuntimeAgentDef`, not a projected `DetectedAgent`. That projection omits `maxPromptArgBytes`, and the
|
|
23
|
+
argv-bound defs (`aider`, `deepseek`) qualify solely through it — judging the projection would have
|
|
24
|
+
dropped two working agents while fixing one broken one.
|
|
25
|
+
|
|
26
|
+
- 8ff5653: Drive Antigravity (`agy`) — the 24th and last agent definition `AgentExecutor` rejected.
|
|
27
|
+
|
|
28
|
+
`AgentExecutor` drove 23 of 24 registered definitions. Antigravity was rejected by an explicit
|
|
29
|
+
`def.id === 'antigravity'` guard, for two reasons that are real but narrow:
|
|
30
|
+
|
|
31
|
+
- **`agy` can print an OAuth sign-in URL to stdout and still exit 0.** Streaming stdout live shows
|
|
32
|
+
that URL to the user as if it were the model's reply — a URL that carries the daemon operator's
|
|
33
|
+
`client_id`/`redirect_uri` into a chat transcript, and that is useless to click anyway (`agy -p`
|
|
34
|
+
print mode has no field to paste the resulting auth code into).
|
|
35
|
+
- **`agy` has no `--model` flag.** The model choice is written into one process-global
|
|
36
|
+
`~/.gemini/antigravity-cli/settings.json` that `agy` reads on its own startup, so two concurrent
|
|
37
|
+
runs race: run A writes model A, A spawns, B writes model B, and only _then_ does A's `agy` read
|
|
38
|
+
the file — so A silently executes on B's model.
|
|
39
|
+
|
|
40
|
+
Both are now met by **declarative `RuntimeAgentDef` fields the executor reads generically**, joining
|
|
41
|
+
the 14 optional behavior flags (`promptViaFile`, `authProbe`, …) that already work this way. There is
|
|
42
|
+
no agent-id branch anywhere in the dispatch path — a deliberate divergence from Open Design's own
|
|
43
|
+
daemon, which hardcodes the id twice.
|
|
44
|
+
|
|
45
|
+
New on `@jini-ai/agent-runtime`'s `RuntimeAgentDef`:
|
|
46
|
+
|
|
47
|
+
- `needsAgentLogFile?: boolean` — asks the caller to stage a temp path and pass it as
|
|
48
|
+
`RuntimeContext.agentLogFilePath` before `buildArgs` runs, mirroring `promptViaFile`. Staged by the
|
|
49
|
+
new `prepareAgentLogFile(def, label)` (the sibling of `preparePromptFileForAgent`; it creates only
|
|
50
|
+
the `0o700` containing directory, since the log file is output the CLI authors, not input we do).
|
|
51
|
+
- `stdoutPolicy?: RuntimeStdoutPolicy` — `{buffering: 'live'}` (the default, and every other
|
|
52
|
+
definition's behavior) or `{buffering: 'until-close', sanitize?}`. A discriminated union rather
|
|
53
|
+
than two flat fields on purpose: a sanitizer is only meaningful on the buffered path, because the
|
|
54
|
+
pattern to redact can straddle two `'data'` chunks. Two independent flags would let a definition
|
|
55
|
+
declare a sanitizer the caller could not honor — a confidentiality gap that _looks_ closed.
|
|
56
|
+
- `runtimeLock?: RuntimeLock` — a mutex around a process-global side effect `buildArgs` performs.
|
|
57
|
+
Acquired before `buildArgs`, released on whichever of `waitForHandoff` settling or process exit
|
|
58
|
+
comes first. Releasing on exit is load-bearing, not a fallback: a watcher that stops polling means
|
|
59
|
+
"I stopped watching", never "the child definitely didn't read the file".
|
|
60
|
+
|
|
61
|
+
All three are stripped from the `DetectedAgent` registry projection. They instruct whoever _spawns_
|
|
62
|
+
the CLI, not whoever lists agents — and two carry closures, which `JSON.stringify` would flatten into
|
|
63
|
+
a misleading `{"buffering":"until-close"}` / `{}` rather than omit.
|
|
64
|
+
|
|
65
|
+
In `@jini-ai/daemon`, `AgentExecutor` now stages a log file when `needsAgentLogFile` is set (released
|
|
66
|
+
on every pre-spawn, spawn-failure, and close path alongside the prompt file, through one composed
|
|
67
|
+
`cleanupStagedFiles` closure), calls `runtimeLock.acquire()` before `buildArgs`, and branches its
|
|
68
|
+
`streamFormat: 'plain'` stdout handling on `stdoutPolicy`. `assessAgentExecutorCompatibility` no
|
|
69
|
+
longer rejects anything by id, and `isAgentExecutorSupported` now accepts all 24 definitions.
|
|
70
|
+
|
|
71
|
+
**No behavior change for any other agent.** Antigravity is the only definition declaring any of the
|
|
72
|
+
three fields; the other four `streamFormat: 'plain'` definitions (`grok-build`, `aider`, `deepseek`,
|
|
73
|
+
`qwen`) keep streaming live, per chunk, which `aider`'s and `deepseek`'s own comments call out as
|
|
74
|
+
deliberate. That is pinned by an explicit regression test rather than left implied.
|
|
75
|
+
|
|
76
|
+
One thing worth knowing if you consume the run event stream: on the buffered path the raw `'stdout'`
|
|
77
|
+
echo is held back and sanitized too, not just the `'agent'`/`text_delta` copy. Emitting an
|
|
78
|
+
unsanitized raw echo while withholding the chat copy would leak the exact string the sanitizer exists
|
|
79
|
+
to remove to any subscriber. The opt-in byte journal still records raw bytes per chunk — that is its
|
|
80
|
+
documented contract, and it deliberately lives in a separate `EventLog` instance that is never
|
|
81
|
+
replayed to run-event subscribers.
|
|
82
|
+
|
|
83
|
+
- 3fe4d14: Store tool-result images once in media, preserve model-facing pixels, and provide opt-in digest-verified echo summaries and bounded image-safe diagnostics. Hosts with exact-byte journaling must inject a separate stdout blob/reference writer.
|
|
84
|
+
|
|
85
|
+
Preserve conflicting image representations during digest verification, and keep the required bridge executor authoritative when route dependencies supply optional settings.
|
|
86
|
+
|
|
87
|
+
- e16a907: Choose reported-cost semantics from agent definition metadata rather than a vendor id; absent metadata retains per-run behavior.
|
|
88
|
+
- 4f52784: Add `createRunScopedContextStore` and widen `ResolvedRunInput` with the executor's optional inputs.
|
|
89
|
+
|
|
90
|
+
**`createRunScopedContextStore<T>({ lifecycle })`** — bind/resolve/auto-evict keyed by run id, for
|
|
91
|
+
host-owned context that must survive the gap between starting a run and a later delegated call that
|
|
92
|
+
carries only that run's id. `resolve` fails closed (`RunContextNotBoundError`) rather than fabricating a
|
|
93
|
+
default, and bindings are evicted when the run reaches a terminal state.
|
|
94
|
+
|
|
95
|
+
`resolveRunInput` could already decode and stash such context, but it is handed no lifecycle and so
|
|
96
|
+
cannot register the eviction half — leaving a map that only grows and keeps stale authority resolvable.
|
|
97
|
+
Owning both halves together is the point. `T` is fully generic: this helper does not decode
|
|
98
|
+
`contextRef`, does not sign or verify anything, and has no notion of a principal. It is in-memory only,
|
|
99
|
+
and says so — after a restart every `resolve` fails, which is the correct posture rather than a gap.
|
|
100
|
+
|
|
101
|
+
**`ResolvedRunInput` now carries `permissionMode`, `model`, `reasoning`, and `credentialEnv`**, each
|
|
102
|
+
forwarded to `AgentExecutor.run()` only when present.
|
|
103
|
+
|
|
104
|
+
`permissionMode` is the load-bearing one and closes a real trap: every def with an auto-approve flag
|
|
105
|
+
(`bypassPermissions` / `--yolo` / `--dangerously-skip-permissions`) applies it by default when the field
|
|
106
|
+
is absent. A host that had been passing `'restricted'` to `AgentExecutor.run()` by hand and then adopted
|
|
107
|
+
`createDefaultRunStartHandler` would silently have started auto-approving every action — a security
|
|
108
|
+
regression wearing the clothes of a refactor. Existing behavior is unchanged: omitted fields are omitted
|
|
109
|
+
from the executor call, never passed as an explicit `undefined`.
|
|
110
|
+
|
|
111
|
+
- 4f52784: Add a `sidecar-strict` security mode and per-run MCP credential propagation.
|
|
112
|
+
|
|
113
|
+
For a daemon whose threat model is **another process running as the same OS user** rather than a
|
|
114
|
+
remote attacker, the existing `jini-local` mode is a no-op: `registerApiBearerAuthMiddleware`
|
|
115
|
+
short-circuits for any loopback peer before it reads the `Authorization` header, and a `127.0.0.1`
|
|
116
|
+
bind keeps remote hosts out while doing nothing about a co-resident process. A consumer that spawns a
|
|
117
|
+
Jini daemon holding real authority — starting agent runs, executing tools against a real database —
|
|
118
|
+
previously had to write its own middleware.
|
|
119
|
+
|
|
120
|
+
- `@jini-ai/http-kit` gains `requireStrictBearerToken`: fail-closed 503 when the named token env var
|
|
121
|
+
is unset, 401 on mismatch, **no loopback exemption and no disable flag**. Its `tokenEnvVar` is
|
|
122
|
+
required with no default, so this package never names a host's secret.
|
|
123
|
+
- `composeJiniKernel` gains `security: { mode: 'sidecar-strict', host, tokenEnvVar, exemptPaths? }`.
|
|
124
|
+
Purely additive — `host` and `jini-local` are unchanged by construction, since the modes are arms of
|
|
125
|
+
a discriminated union. The strict gate mounts ahead of the JSON body parser, so a caller it rejects
|
|
126
|
+
never has its body parsed.
|
|
127
|
+
- `@jini-ai/daemon`'s `McpJsonInjectionOptions` gains `credential?: (runId) => string | Promise<string>`
|
|
128
|
+
— a **resolver, not a string**, because injection options are built once before any run exists and a
|
|
129
|
+
boot-wide shared secret would defeat the point of scoping a credential to a run. It is delivered to
|
|
130
|
+
the child as `JINI_DAEMON_TOKEN`.
|
|
131
|
+
- `@jini-ai/mcp`'s `jini-mcp` reads that variable and attaches `Authorization` to every daemon call.
|
|
132
|
+
Optional throughout: with no credential, request headers and `.mcp.json` output are byte-identical
|
|
133
|
+
to before.
|
|
134
|
+
|
|
135
|
+
Also generalized: both existing bearer gates now compare tokens in constant time (`timingSafeEqual`)
|
|
136
|
+
and share one header-parsing helper, closing a timing side channel and removing a duplicated regex.
|
|
137
|
+
|
|
138
|
+
- Updated dependencies [22f0619]
|
|
139
|
+
- Updated dependencies [82ed2d3]
|
|
140
|
+
- Updated dependencies [3fe4d14]
|
|
141
|
+
- Updated dependencies [5a3de86]
|
|
142
|
+
- @jini-ai/core@0.6.10
|
|
143
|
+
- @jini-ai/protocol@0.6.10
|
|
144
|
+
|
|
1
145
|
## 0.6.9 — 2026-10-22
|
|
2
146
|
|
|
3
147
|
- 2026-10-22: Memory fixes (bounded event log and tool-executor audit), chromeIntegration option, provider-plugin registration, coverage.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { DelegatedResultCorrelation } from '../delegated-result-correlation.js';
|
|
2
|
+
import type { StdoutJournalBlobPort } from '../tool-result-diagnostics.js';
|
|
1
3
|
import type { MessageAttachmentImage } from '../attachment-content.js';
|
|
2
4
|
import type { probeMcpBridge } from './mcp-bridge-probe.js';
|
|
3
5
|
import type { spawn as nodeSpawn, ChildProcess } from 'node:child_process';
|
|
@@ -600,6 +602,10 @@ export interface FailureClassificationContext {
|
|
|
600
602
|
*/
|
|
601
603
|
export type ClassifyFailure = (context: FailureClassificationContext) => boolean | Promise<boolean>;
|
|
602
604
|
export interface CreateAgentExecutorOptions {
|
|
605
|
+
/** Negotiated v1 only; share with bridge/lifecycle. Absent preserves legacy diagnostics/echoes. */
|
|
606
|
+
readonly delegatedResultCorrelation?: DelegatedResultCorrelation;
|
|
607
|
+
/** Required with correlation + byte journal: exact stdout blobs and durable reference records. */
|
|
608
|
+
readonly stdoutJournalBlobs?: StdoutJournalBlobPort;
|
|
603
609
|
/** Host environment snapshot; baseline spawn keys and source config paths are allowlisted. */
|
|
604
610
|
readonly hostEnv?: NodeJS.ProcessEnv;
|
|
605
611
|
readonly runtimeState?: AgentRuntimeState;
|
|
@@ -101,6 +101,7 @@ function translateToolInputDeltaEvent(rawEvent) {
|
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
export function translateToolResultEvent({ rawEvent }) {
|
|
104
|
+
const delegatedToolUseId = asOptionalString(rawEvent.delegatedToolUseId);
|
|
104
105
|
const isError = typeof rawEvent.isError === 'boolean' ? rawEvent.isError : undefined;
|
|
105
106
|
return {
|
|
106
107
|
kind: 'agent',
|
|
@@ -108,6 +109,7 @@ export function translateToolResultEvent({ rawEvent }) {
|
|
|
108
109
|
type: 'tool_result',
|
|
109
110
|
toolUseId: asString(rawEvent.toolUseId),
|
|
110
111
|
content: asString(rawEvent.content),
|
|
112
|
+
...(delegatedToolUseId !== undefined ? { delegatedToolUseId } : {}),
|
|
111
113
|
...(isError !== undefined ? { isError } : {}),
|
|
112
114
|
},
|
|
113
115
|
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { DelegatedResultCorrelation } from '../delegated-result-correlation.js';
|
|
2
|
+
import type { StdoutJournalBlobPort } from '../tool-result-diagnostics.js';
|
|
1
3
|
import type { probeMcpBridge } from './mcp-bridge-probe.js';
|
|
2
4
|
import type { ModelCatalogSnapshot } from '@jini-ai/agent-runtime';
|
|
3
5
|
import type { ChildProcess } from 'node:child_process';
|
|
@@ -24,6 +26,8 @@ export declare function defaultCleanupFailureSink(context: AgentCleanupFailureCo
|
|
|
24
26
|
*/
|
|
25
27
|
export declare const DEFAULT_BUFFERED_STDOUT_MAX_BYTES: number;
|
|
26
28
|
interface WireChildLifecycleContext extends TerminateChildTreeDeps {
|
|
29
|
+
readonly delegatedResultCorrelation?: DelegatedResultCorrelation;
|
|
30
|
+
readonly stdoutJournalBlobs?: StdoutJournalBlobPort;
|
|
27
31
|
readonly startingModel?: string;
|
|
28
32
|
readonly modelCatalog?: ModelCatalogSnapshot;
|
|
29
33
|
readonly runId: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createToolResultDiagnosticProjection, projectDiagnosticFrame } from '../tool-result-diagnostics.js';
|
|
1
2
|
import { appendSerialTask } from "@jini-ai/core/primitives";
|
|
2
3
|
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { createModelReceiptTracker } from './model-receipts.js';
|
|
@@ -9,7 +10,7 @@ import { collectProcessTreePids, listProcessSnapshots, stopProcesses, } from '@j
|
|
|
9
10
|
import { classifyRunCloseStatus, } from '../close-status.js';
|
|
10
11
|
import { resolveContinuationTransport, } from '../continuation/continuation-transport.js';
|
|
11
12
|
import { resultContent, } from '../delegated-tool-bridge.js';
|
|
12
|
-
import {
|
|
13
|
+
import { projectToolResultEvent, } from '../tool-result-media.js';
|
|
13
14
|
import { translateAgentRuntimeEvent, translateAcpError, applyAgentTranslationSideEffects, } from './event-translation.js';
|
|
14
15
|
import { errorMessage, isRecord, } from './values.js';
|
|
15
16
|
import { MCP_BRIDGE_UNAVAILABLE, unavailableJiniBridgeStatus, bridgeUnavailableMessage, } from './mcp-bridge.js';
|
|
@@ -202,6 +203,35 @@ export const DEFAULT_BUFFERED_STDOUT_MAX_BYTES = 8 * 1024 * 1024;
|
|
|
202
203
|
function bufferedStdoutTruncationNotice(droppedBytes, maxBytes) {
|
|
203
204
|
return `\n[jini] agent stdout truncated: ${droppedBytes} byte(s) dropped after the ${maxBytes}-byte buffer limit was reached.\n`;
|
|
204
205
|
}
|
|
206
|
+
/** Keeps exact-byte auditing separate from retained diagnostics on the run's existing FIFO.
|
|
207
|
+
* Projection retains at most one bounded frame; ordinary stdout keeps its legacy byte journal. */
|
|
208
|
+
function createStdoutDiagnostics({ ctx, enqueueEmit }, _options = {}) {
|
|
209
|
+
const { runId, lifecycle, journal, stdoutJournalBlobs } = ctx;
|
|
210
|
+
const projection = ctx.delegatedResultCorrelation && ctx.streamFormat === 'claude-stream-json'
|
|
211
|
+
? createToolResultDiagnosticProjection({}) : undefined;
|
|
212
|
+
function emit({ text }) {
|
|
213
|
+
if (text)
|
|
214
|
+
enqueueEmit(() => lifecycle.emit({ runId, input: { event: 'stdout', data: { chunk: text } } }));
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
record({ chunk, text }) {
|
|
218
|
+
if (!journal)
|
|
219
|
+
return;
|
|
220
|
+
if (projection && stdoutJournalBlobs) {
|
|
221
|
+
enqueueEmit(() => stdoutJournalBlobs.record({ runId, chunk }));
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
enqueueEmit(() => journal.record({ runId, entry: receivedJournalEntry('stdout', text) }));
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
feed({ text }) {
|
|
228
|
+
emit({ text: projection ? projection.feed({ chunk: text }) : text });
|
|
229
|
+
},
|
|
230
|
+
flush() {
|
|
231
|
+
emit({ text: projection?.flush({}) ?? '' });
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
205
235
|
/**
|
|
206
236
|
* Wires one spawned child's full observable lifecycle: raw stdout/stderr
|
|
207
237
|
* forwarding, structured stream-parser dispatch (translated via
|
|
@@ -264,6 +294,7 @@ function bufferedStdoutTruncationNotice(droppedBytes, maxBytes) {
|
|
|
264
294
|
*/
|
|
265
295
|
export function wireChildLifecycle(ctx) {
|
|
266
296
|
const { runId, def, streamFormat, child, lifecycle, journal, continuation, classifyFailure } = ctx;
|
|
297
|
+
const stdoutDiagnostics = createStdoutDiagnostics({ ctx, enqueueEmit });
|
|
267
298
|
const output = observeRunOutput({ lifecycle, runId, agentName: def.name }, { plainStdout: streamFormat === 'plain' });
|
|
268
299
|
const modelReceipt = createModelReceiptTracker(ctx.startingModel, ctx.modelCatalog?.models);
|
|
269
300
|
let stdinClosed = false;
|
|
@@ -478,11 +509,8 @@ export function wireChildLifecycle(ctx) {
|
|
|
478
509
|
const run = { id: runId };
|
|
479
510
|
let content;
|
|
480
511
|
let isError;
|
|
481
|
-
//
|
|
482
|
-
|
|
483
|
-
// `splitToolResultSurfaces` (it has no `mcp-ui` withhold-from-model concept — the flattened
|
|
484
|
-
// `content` below already carries the whole raw output, a pre-existing, unrelated gap), so
|
|
485
|
-
// there is no `remainder` to thread back in — only the extracted blocks are used here.
|
|
512
|
+
// Event projection removes pixels only from saved content; stdin keeps the original.
|
|
513
|
+
let eventContent;
|
|
486
514
|
let media = [];
|
|
487
515
|
// Suspend the slow-run watchdog for the duration of this daemon-awaited execution: the daemon
|
|
488
516
|
// knows exactly why the run is quiet here (it dispatched the tool itself and is waiting on it),
|
|
@@ -494,11 +522,14 @@ export function wireChildLifecycle(ctx) {
|
|
|
494
522
|
try {
|
|
495
523
|
const result = await continuation.toolExecutor.execute({ principal: continuation.principal, run: run, toolId: toolUse.name, input: toolUse.input });
|
|
496
524
|
content = resultContent({ result: result });
|
|
497
|
-
|
|
498
|
-
|
|
525
|
+
const projection = projectToolResultEvent({ result });
|
|
526
|
+
eventContent = projection.content;
|
|
527
|
+
isError = projection.isError === true;
|
|
528
|
+
media = projection.media ?? [];
|
|
499
529
|
}
|
|
500
530
|
catch (error) {
|
|
501
531
|
content = errorMessage(error);
|
|
532
|
+
eventContent = content;
|
|
502
533
|
isError = true;
|
|
503
534
|
}
|
|
504
535
|
finally {
|
|
@@ -509,7 +540,7 @@ export function wireChildLifecycle(ctx) {
|
|
|
509
540
|
data: {
|
|
510
541
|
type: 'tool_result',
|
|
511
542
|
toolUseId: toolUse.id,
|
|
512
|
-
content,
|
|
543
|
+
content: eventContent,
|
|
513
544
|
...(isError ? { isError: true } : {}),
|
|
514
545
|
...(media.length > 0 ? { media } : {}),
|
|
515
546
|
},
|
|
@@ -539,7 +570,10 @@ export function wireChildLifecycle(ctx) {
|
|
|
539
570
|
} });
|
|
540
571
|
if (rejectUnavailableBridge({ payload: translation.payload, rawEvent }, {}))
|
|
541
572
|
return;
|
|
542
|
-
enqueueEmit(() =>
|
|
573
|
+
enqueueEmit(() => {
|
|
574
|
+
const payload = projectEcho({ payload: translation.payload, rawEvent }, {});
|
|
575
|
+
return lifecycle.emit({ runId, input: { event: 'agent', data: statusWithChildIdentity({ payload, childPid: ctx.child.pid }, {}) } });
|
|
576
|
+
});
|
|
543
577
|
}
|
|
544
578
|
else if (translation.kind === 'error') {
|
|
545
579
|
enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'error', data: translation.payload } }));
|
|
@@ -548,6 +582,17 @@ export function wireChildLifecycle(ctx) {
|
|
|
548
582
|
handleTurnEnd(translation.stopReason);
|
|
549
583
|
}
|
|
550
584
|
});
|
|
585
|
+
/** Verify against an already-saved call in this run; unverified metadata preserves all output. */
|
|
586
|
+
function projectEcho({ payload, rawEvent }, _options = {}) {
|
|
587
|
+
if (!ctx.delegatedResultCorrelation)
|
|
588
|
+
return payload;
|
|
589
|
+
if (payload.type === 'raw')
|
|
590
|
+
return { ...payload, line: projectDiagnosticFrame({ line: payload.line }) };
|
|
591
|
+
if (payload.type !== 'tool_result' || !payload.delegatedToolUseId || !Array.isArray(rawEvent.delegatedResultContent))
|
|
592
|
+
return payload;
|
|
593
|
+
const matches = ctx.delegatedResultCorrelation.matches({ runId, toolUseId: payload.delegatedToolUseId, content: rawEvent.delegatedResultContent, isError: payload.isError === true });
|
|
594
|
+
return matches ? { ...payload, content: `Result shown by delegated call ${payload.delegatedToolUseId}.` } : payload;
|
|
595
|
+
}
|
|
551
596
|
function rejectUnavailableBridge({ payload, rawEvent }, _options = {}) {
|
|
552
597
|
if (!bridgeChecked && payload.type === 'status' && payload.label === 'initializing') {
|
|
553
598
|
bridgeChecked = true;
|
|
@@ -616,8 +661,7 @@ export function wireChildLifecycle(ctx) {
|
|
|
616
661
|
}
|
|
617
662
|
child.stdout?.on('data', (chunk) => {
|
|
618
663
|
const text = chunk.toString('utf8');
|
|
619
|
-
|
|
620
|
-
enqueueEmit(() => journal.record({ runId: runId, entry: receivedJournalEntry('stdout', text) }));
|
|
664
|
+
stdoutDiagnostics.record({ chunk, text });
|
|
621
665
|
if (streamFormat === 'plain') {
|
|
622
666
|
if (text.length > 0)
|
|
623
667
|
userVisibleOutputSeen = true;
|
|
@@ -643,7 +687,7 @@ export function wireChildLifecycle(ctx) {
|
|
|
643
687
|
enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'agent', data: { type: 'text_delta', delta: text } } }));
|
|
644
688
|
return;
|
|
645
689
|
}
|
|
646
|
-
|
|
690
|
+
stdoutDiagnostics.feed({ text });
|
|
647
691
|
// Before the parser: an ack and the aborted turn's end can share a chunk, and the turn end must
|
|
648
692
|
// see that no interrupt is still waiting (see `stdinHoldAfterTurnEnd`); a background task
|
|
649
693
|
// starting and the turn's end can share one too.
|
|
@@ -686,6 +730,7 @@ export function wireChildLifecycle(ctx) {
|
|
|
686
730
|
// driver dispatches to. `streamHandler` is null for `'plain'` (no
|
|
687
731
|
// parser, hence nothing to flush) — `?.` skips it cleanly.
|
|
688
732
|
streamHandler?.flush();
|
|
733
|
+
stdoutDiagnostics.flush();
|
|
689
734
|
// Queued before `await emitQueue` so the flushed text is durably appended ahead of
|
|
690
735
|
// `finish()`'s `'end'` event, exactly like every live-path emit already is.
|
|
691
736
|
flushBufferedStdout();
|
|
@@ -381,6 +381,6 @@ export declare function confirmChildSpawned({ input, deps }: {
|
|
|
381
381
|
* @complexity `run()`'s own setup is O(1); steady-state cost is the chosen stream parser's.
|
|
382
382
|
* @overallScore 100/100
|
|
383
383
|
*/
|
|
384
|
-
export declare function createAgentExecutor(requiredArgs: Pick<CreateAgentExecutorOptions, "lifecycle">, optionalArgs?: Pick<CreateAgentExecutorOptions, "runtimeState" | "codexDisablePlugins" | "piGracefulShutdownMs" | "hostEnv" | "getAgentDef" | "resolveAgentLaunch" | "resolveModelForLaunch" | "ensureAgentCapabilities" | "applyAgentLaunchEnv" | "createCommandInvocation" | "spawn" | "probeMcpBridge" | "attachAcpSession" | "acpPermissionHandler" | "attachPiRpcSession" | "preparePromptFileForAgent" | "prepareAgentLogFile" | "listProcessSnapshots" | "collectProcessTreePids" | "stopProcesses" | "onCleanupFailure" | "journal" | "continuation" | "classifyFailure" | "mcpJsonInjection" | "claudeConfigDirIsolation" | "claudeConfigDirIsolationEnabled" | "bufferedStdoutMaxBytes" | "promptAugmenter">): AgentExecutor;
|
|
384
|
+
export declare function createAgentExecutor(requiredArgs: Pick<CreateAgentExecutorOptions, "lifecycle">, optionalArgs?: Pick<CreateAgentExecutorOptions, "runtimeState" | "codexDisablePlugins" | "piGracefulShutdownMs" | "hostEnv" | "getAgentDef" | "resolveAgentLaunch" | "resolveModelForLaunch" | "ensureAgentCapabilities" | "applyAgentLaunchEnv" | "createCommandInvocation" | "spawn" | "probeMcpBridge" | "attachAcpSession" | "acpPermissionHandler" | "attachPiRpcSession" | "preparePromptFileForAgent" | "prepareAgentLogFile" | "listProcessSnapshots" | "collectProcessTreePids" | "stopProcesses" | "onCleanupFailure" | "journal" | "delegatedResultCorrelation" | "stdoutJournalBlobs" | "continuation" | "classifyFailure" | "mcpJsonInjection" | "claudeConfigDirIsolation" | "claudeConfigDirIsolationEnabled" | "bufferedStdoutMaxBytes" | "promptAugmenter">): AgentExecutor;
|
|
385
385
|
export {};
|
|
386
386
|
//# sourceMappingURL=launch.d.ts.map
|
|
@@ -616,6 +616,12 @@ export async function confirmChildSpawned({ input, deps }) {
|
|
|
616
616
|
function nativeCapabilityInput({ input }, _options = {}) {
|
|
617
617
|
return { ...(input.nativeCapabilities !== undefined ? { nativeCapabilities: input.nativeCapabilities } : {}) };
|
|
618
618
|
}
|
|
619
|
+
/** Correlation projects retained stdout, so exact-byte journals need a separate blob writer. */
|
|
620
|
+
function validateStdoutJournalOptions(options, _options = {}) {
|
|
621
|
+
if (options.delegatedResultCorrelation && options.journal && !options.stdoutJournalBlobs) {
|
|
622
|
+
throw new Error('delegated result correlation with a byte journal requires stdoutJournalBlobs');
|
|
623
|
+
}
|
|
624
|
+
}
|
|
619
625
|
/** Model discovery consumes the exact spawn environment and caller-supplied settings. */
|
|
620
626
|
function launchModelContext({ input, launch, spawnEnv }, _options = {}) {
|
|
621
627
|
return {
|
|
@@ -664,6 +670,7 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
664
670
|
const { getAgentDef: getAgentDefFn, resolveAgentLaunch: resolveAgentLaunchFn, ensureAgentCapabilities: ensureAgentCapabilitiesFn, applyAgentLaunchEnv: applyAgentLaunchEnvFn, attachAcpSession: attachAcpSessionFn, attachPiRpcSession: attachPiRpcSessionFn, preparePromptFileForAgent: preparePromptFileForAgentFn, prepareAgentLogFile: prepareAgentLogFileFn, } = resolveAgentRuntimeDeps({ ...options, hostEnv });
|
|
665
671
|
const { createCommandInvocation: createCommandInvocationFn, spawn: spawnFn, listProcessSnapshots: listProcessSnapshotsFn, collectProcessTreePids: collectProcessTreePidsFn, stopProcesses: stopProcessesFn, } = resolveProcessDeps(options);
|
|
666
672
|
const { onCleanupFailure: onCleanupFailureFn, bufferedStdoutMaxBytes } = resolveMiscExecutorDeps(options);
|
|
673
|
+
validateStdoutJournalOptions(options);
|
|
667
674
|
const journal = options.journal;
|
|
668
675
|
const continuation = options.continuation;
|
|
669
676
|
const classifyFailure = options.classifyFailure;
|
|
@@ -811,6 +818,8 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
811
818
|
onCleanupFailure: onCleanupFailureFn,
|
|
812
819
|
cleanupStagedFiles,
|
|
813
820
|
journal,
|
|
821
|
+
...(options.delegatedResultCorrelation ? { delegatedResultCorrelation: options.delegatedResultCorrelation } : {}),
|
|
822
|
+
...(options.stdoutJournalBlobs ? { stdoutJournalBlobs: options.stdoutJournalBlobs } : {}),
|
|
814
823
|
continuation,
|
|
815
824
|
classifyFailure,
|
|
816
825
|
bufferedStdoutMaxBytes,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CredentialCryptoPort } from './run-credentials.js';
|
|
2
|
+
interface ResultIdentity {
|
|
3
|
+
readonly runId: string;
|
|
4
|
+
readonly toolUseId: string;
|
|
5
|
+
readonly content: readonly unknown[];
|
|
6
|
+
readonly isError: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** Run-scoped proof of a saved bridge result. Implementations retain digests, never pixels. */
|
|
9
|
+
export interface DelegatedResultCorrelation {
|
|
10
|
+
record(input: ResultIdentity, options?: Record<string, never>): void;
|
|
11
|
+
matches(input: ResultIdentity, options?: Record<string, never>): boolean;
|
|
12
|
+
clearRun(input: {
|
|
13
|
+
runId: string;
|
|
14
|
+
}, options?: Record<string, never>): void;
|
|
15
|
+
}
|
|
16
|
+
/** Bounded digest store. Eviction only disables summarization, so legacy output remains safe.
|
|
17
|
+
* Share this instance with bridge/executor/lifecycle; lifecycle eviction clears each run.
|
|
18
|
+
* @complexity O(n) hashing plus JSON key sorting; O(r*c) retained fixed-size digests. */
|
|
19
|
+
export declare function createDelegatedResultCorrelation({ crypto }: {
|
|
20
|
+
crypto: Pick<CredentialCryptoPort, 'digest'>;
|
|
21
|
+
}, { maxRuns, maxCallsPerRun }?: {
|
|
22
|
+
maxRuns?: number;
|
|
23
|
+
maxCallsPerRun?: number;
|
|
24
|
+
}): DelegatedResultCorrelation;
|
|
25
|
+
export {};
|
|
26
|
+
//# sourceMappingURL=delegated-result-correlation.d.ts.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Accept only the complete base64 source shape; extra fields must remain part of the digest. */
|
|
2
|
+
function base64ImageSource({ source }, _options = {}) {
|
|
3
|
+
if (typeof source !== 'object' || source === null)
|
|
4
|
+
return undefined;
|
|
5
|
+
const image = source;
|
|
6
|
+
if (image.type !== 'base64' || typeof image.media_type !== 'string' || typeof image.data !== 'string' || Object.keys(image).length !== 3)
|
|
7
|
+
return undefined;
|
|
8
|
+
return { data: image.data, media_type: image.media_type };
|
|
9
|
+
}
|
|
10
|
+
/** Convert only source-only images; dual representations and extra source fields stay in the proof. */
|
|
11
|
+
function normalizeSourceImage({ record }, _options = {}) {
|
|
12
|
+
if (record.type !== 'image' || 'data' in record || 'mimeType' in record)
|
|
13
|
+
return record;
|
|
14
|
+
const image = base64ImageSource({ source: record.source });
|
|
15
|
+
if (!image)
|
|
16
|
+
return record;
|
|
17
|
+
const { source: _source, ...rest } = record;
|
|
18
|
+
return { ...rest, data: image.data, mimeType: image.media_type };
|
|
19
|
+
}
|
|
20
|
+
/** Normalize unambiguous CLI images and JSON object key order.
|
|
21
|
+
* @complexity O(n + k log k) for n bytes and k keys per object; inputs are wire JSON. */
|
|
22
|
+
function normalizedJson(_key, value) {
|
|
23
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
24
|
+
return value;
|
|
25
|
+
const record = normalizeSourceImage({ record: value });
|
|
26
|
+
return Object.fromEntries(Object.keys(record).sort().map(key => [key, record[key]]));
|
|
27
|
+
}
|
|
28
|
+
/** Bounded digest store. Eviction only disables summarization, so legacy output remains safe.
|
|
29
|
+
* Share this instance with bridge/executor/lifecycle; lifecycle eviction clears each run.
|
|
30
|
+
* @complexity O(n) hashing plus JSON key sorting; O(r*c) retained fixed-size digests. */
|
|
31
|
+
export function createDelegatedResultCorrelation({ crypto }, { maxRuns = 256, maxCallsPerRun = 2048 } = {}) {
|
|
32
|
+
if (!Number.isSafeInteger(maxRuns) || maxRuns < 1 || !Number.isSafeInteger(maxCallsPerRun) || maxCallsPerRun < 1)
|
|
33
|
+
throw new Error('correlation limits must be positive integers');
|
|
34
|
+
const runs = new Map();
|
|
35
|
+
// Optional correlation must not turn an otherwise serializable legacy fallback into a failure
|
|
36
|
+
// (for example, a cyclic in-process tool result handled by serializeDelegatedToolOutput).
|
|
37
|
+
const digest = (input) => {
|
|
38
|
+
try {
|
|
39
|
+
return crypto.digest({ token: JSON.stringify({ isError: input.isError, content: input.content }, normalizedJson) });
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
record(input) {
|
|
47
|
+
const fingerprint = digest(input);
|
|
48
|
+
if (fingerprint === undefined)
|
|
49
|
+
return;
|
|
50
|
+
let calls = runs.get(input.runId);
|
|
51
|
+
if (!calls) {
|
|
52
|
+
if (runs.size >= maxRuns)
|
|
53
|
+
runs.delete(runs.keys().next().value);
|
|
54
|
+
calls = new Map();
|
|
55
|
+
runs.set(input.runId, calls);
|
|
56
|
+
}
|
|
57
|
+
if (calls.size >= maxCallsPerRun)
|
|
58
|
+
calls.delete(calls.keys().next().value);
|
|
59
|
+
calls.set(input.toolUseId, fingerprint);
|
|
60
|
+
},
|
|
61
|
+
matches(input) {
|
|
62
|
+
const expected = runs.get(input.runId)?.get(input.toolUseId);
|
|
63
|
+
return expected !== undefined && expected === digest(input);
|
|
64
|
+
},
|
|
65
|
+
clearRun({ runId }) { runs.delete(runId); },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=delegated-result-correlation.js.map
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { type Principal } from '@jini-ai/core';
|
|
10
10
|
import type { RunLifecycle } from './run-lifecycle.js';
|
|
11
|
+
import type { DelegatedResultCorrelation } from './delegated-result-correlation.js';
|
|
11
12
|
import type { ToolExecutionResult, ToolExecutor } from './tool-executor.js';
|
|
12
13
|
/**
|
|
13
14
|
* Run-protocol `type` for a human-only surface withheld from a tool result.
|
|
@@ -42,24 +43,15 @@ export interface DelegatedToolBridge {
|
|
|
42
43
|
export interface CreateDelegatedToolBridgeOptions {
|
|
43
44
|
readonly lifecycle: RunLifecycle;
|
|
44
45
|
readonly toolExecutor: ToolExecutor;
|
|
46
|
+
/** Share with the executor and lifecycle only after the host negotiates marker v1. */
|
|
47
|
+
readonly delegatedResultCorrelation?: DelegatedResultCorrelation;
|
|
45
48
|
}
|
|
46
|
-
|
|
47
|
-
export declare function serializeDelegatedToolOutput({ output }: {
|
|
48
|
-
readonly output: unknown;
|
|
49
|
-
}): string;
|
|
50
|
-
/**
|
|
51
|
-
* Maps a `ToolExecutionResult`'s status to the string a delegated-tool caller (this bridge, or
|
|
52
|
-
* gap 3's stdin-tool-result injector in `agent-executor.ts`) reports back as the tool's visible
|
|
53
|
-
* output/failure reason. Exported so both real callers share one mapping rather than drifting.
|
|
54
|
-
*/
|
|
55
|
-
export declare function resultContent({ result }: {
|
|
56
|
-
readonly result: ToolExecutionResult;
|
|
57
|
-
}): string;
|
|
49
|
+
export { serializeDelegatedToolOutput, resultContent } from './tool-result-media.js';
|
|
58
50
|
/**
|
|
59
51
|
* Creates the transport-neutral bridge used by future ACP-delegate, MCP, or
|
|
60
52
|
* other host protocols. This module does not invent a server transport: a
|
|
61
53
|
* concrete protocol calls this bridge after it has decoded and authenticated a
|
|
62
54
|
* delegated tool request.
|
|
63
55
|
*/
|
|
64
|
-
export declare function createDelegatedToolBridge(
|
|
56
|
+
export declare function createDelegatedToolBridge(requiredArgs: Pick<CreateDelegatedToolBridgeOptions, 'lifecycle' | 'toolExecutor'>, optionalArgs?: Pick<CreateDelegatedToolBridgeOptions, 'delegatedResultCorrelation'>): DelegatedToolBridge;
|
|
65
57
|
//# sourceMappingURL=delegated-tool-bridge.d.ts.map
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* enforces Jini's registry policy, confirmation, timeout, cancellation, and
|
|
7
7
|
* audit trail before any registered handler runs.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import {} from '@jini-ai/core';
|
|
10
|
+
import { projectToolResultEvent } from './tool-result-media.js';
|
|
11
11
|
import { splitToolResultSurfaces } from './tool-result-surfaces.js';
|
|
12
12
|
/**
|
|
13
13
|
* Run-protocol `type` for a human-only surface withheld from a tool result.
|
|
@@ -38,41 +38,7 @@ function correlationFor(channel, toolUseId) {
|
|
|
38
38
|
function errorMessage(error) {
|
|
39
39
|
return error instanceof Error ? error.message : String(error);
|
|
40
40
|
}
|
|
41
|
-
|
|
42
|
-
export function serializeDelegatedToolOutput({ output }) {
|
|
43
|
-
if (typeof output === 'string')
|
|
44
|
-
return output;
|
|
45
|
-
if (output === undefined)
|
|
46
|
-
return '';
|
|
47
|
-
try {
|
|
48
|
-
const serialized = JSON.stringify(output);
|
|
49
|
-
return serialized === undefined ? String(output) : serialized;
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
52
|
-
return String(output);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* Maps a `ToolExecutionResult`'s status to the string a delegated-tool caller (this bridge, or
|
|
57
|
-
* gap 3's stdin-tool-result injector in `agent-executor.ts`) reports back as the tool's visible
|
|
58
|
-
* output/failure reason. Exported so both real callers share one mapping rather than drifting.
|
|
59
|
-
*/
|
|
60
|
-
export function resultContent({ result }) {
|
|
61
|
-
switch (result.status) {
|
|
62
|
-
case 'completed':
|
|
63
|
-
return serializeDelegatedToolOutput({ output: result.output });
|
|
64
|
-
case 'denied':
|
|
65
|
-
return 'Tool execution denied by policy.';
|
|
66
|
-
case 'confirmation-denied':
|
|
67
|
-
return 'Tool execution denied during confirmation.';
|
|
68
|
-
case 'timed-out':
|
|
69
|
-
return 'Tool execution timed out.';
|
|
70
|
-
case 'cancelled':
|
|
71
|
-
return 'Tool execution cancelled.';
|
|
72
|
-
case 'failed':
|
|
73
|
-
return result.error ?? 'Tool execution failed.';
|
|
74
|
-
}
|
|
75
|
-
}
|
|
41
|
+
export { serializeDelegatedToolOutput, resultContent } from './tool-result-media.js';
|
|
76
42
|
/**
|
|
77
43
|
* Builds the abort controller handed to `ToolExecutor`, aborted by either a run cancellation or the
|
|
78
44
|
* invocation's own transport signal. `dispose()` detaches both listeners once the call settles.
|
|
@@ -168,21 +134,7 @@ function createSurfaceSeam({ lifecycle, runId, toolUseId }) {
|
|
|
168
134
|
return { emitSurface, settle, release };
|
|
169
135
|
}
|
|
170
136
|
/** Splits human-only surfaces out of an executed result, emits them, then the matching `tool_result`. */
|
|
171
|
-
async function emitExecutedResult({ lifecycle, runId, toolUseId }, { executed }) {
|
|
172
|
-
// Recognized media blocks (images) are collected here purely to populate the sibling
|
|
173
|
-
// `media` field the `tool_result` event carries below, for a UI that wants to render one
|
|
174
|
-
// inline without re-parsing `content` (`ToolCard`'s `<img>`, per `assistant_demo_image`'s
|
|
175
|
-
// own doc). This does NOT remove the image block from `executed.output` — `image` is one
|
|
176
|
-
// of `tool-result-surfaces.ts`'s own `MODEL_VISIBLE_BLOCK_TYPES`, so the split below already
|
|
177
|
-
// keeps it in `modelOutput` on its own. An image reaching the model IS the point
|
|
178
|
-
// (`assistant_demo_image`/`admin.capture_screenshot` exist to prove exactly that round
|
|
179
|
-
// trip) — unlike an `mcp-ui` resource, which is genuinely human-only and must never survive
|
|
180
|
-
// into `modelOutput`. (Earlier this extraction fed its own de-imaged `remainder` into the
|
|
181
|
-
// surfaces split below and discarded the image from `output` entirely, on the assumption
|
|
182
|
-
// that the sibling `media` field was the only channel a caller needed — true for a UI
|
|
183
|
-
// reading the run's event stream, false for `@jini-ai/mcp`'s `execute_delegated_tool`, which
|
|
184
|
-
// hands the bridge's RETURN VALUE to the model with no access to that event stream at all.)
|
|
185
|
-
const { media } = extractResultMedia({ output: executed.output });
|
|
137
|
+
async function emitExecutedResult({ lifecycle, runId, toolUseId, delegatedResultCorrelation }, { executed }) {
|
|
186
138
|
// THE MODEL/HUMAN FORK. A tool call's return value is definitionally what the model
|
|
187
139
|
// receives, so a UI resource left inside it is model-visible context no matter what any
|
|
188
140
|
// downstream layer does — `@jini-ai/mcp`'s `okResult()` JSON.stringifies the whole result
|
|
@@ -202,32 +154,41 @@ async function emitExecutedResult({ lifecycle, runId, toolUseId }, { executed })
|
|
|
202
154
|
data: { type: MCP_UI_EVENT_TYPE, toolUseId, resource: surface },
|
|
203
155
|
} });
|
|
204
156
|
}
|
|
157
|
+
// The return keeps model-visible images; only the stored event removes their duplicate.
|
|
158
|
+
const projection = projectToolResultEvent({ result });
|
|
205
159
|
await lifecycle.emit({ runId: runId, input: {
|
|
206
160
|
event: 'agent',
|
|
207
161
|
data: {
|
|
208
162
|
type: 'tool_result',
|
|
209
163
|
toolUseId,
|
|
210
|
-
|
|
211
|
-
...(result.status === 'completed' && (batchOutcomeFor({ output: result.output }) ?? 'completed') === 'completed' ? {} : { isError: true }),
|
|
212
|
-
...(media.length > 0 ? { media } : {}),
|
|
164
|
+
...projection,
|
|
213
165
|
},
|
|
214
166
|
} });
|
|
167
|
+
// Record only after the canonical result is saved; failed emission cannot hide an echo.
|
|
168
|
+
if (result.status === 'completed' && isContentEnvelope(result.output)) {
|
|
169
|
+
delegatedResultCorrelation?.record({ runId, toolUseId, content: result.output.content, isError: projection.isError === true });
|
|
170
|
+
}
|
|
215
171
|
return result;
|
|
216
172
|
}
|
|
173
|
+
function isContentEnvelope(output) {
|
|
174
|
+
return typeof output === 'object' && output !== null && 'content' in output && Array.isArray(output.content);
|
|
175
|
+
}
|
|
217
176
|
/**
|
|
218
177
|
* Creates the transport-neutral bridge used by future ACP-delegate, MCP, or
|
|
219
178
|
* other host protocols. This module does not invent a server transport: a
|
|
220
179
|
* concrete protocol calls this bridge after it has decoded and authenticated a
|
|
221
180
|
* delegated tool request.
|
|
222
181
|
*/
|
|
223
|
-
export function createDelegatedToolBridge(
|
|
224
|
-
const {
|
|
182
|
+
export function createDelegatedToolBridge(requiredArgs, optionalArgs = {}) {
|
|
183
|
+
const options = { ...requiredArgs, ...optionalArgs };
|
|
184
|
+
// A route may pass its broader deps as options; its wrapped executor remains authoritative.
|
|
185
|
+
const { lifecycle, toolExecutor } = requiredArgs;
|
|
225
186
|
async function execute(requiredArgs, optionalArgs = {}) {
|
|
226
187
|
// Merged rather than read from `optionalArgs` alone: a caller may hand `signal` in the first
|
|
227
188
|
// object (the delegated-tools HTTP route does), and it must still be honoured.
|
|
228
189
|
const invocation = { ...requiredArgs, ...optionalArgs };
|
|
229
190
|
const { runId, toolUseId, toolId, principal, input } = invocation;
|
|
230
|
-
const call = { lifecycle, runId, toolUseId };
|
|
191
|
+
const call = { lifecycle, runId, toolUseId, ...(options.delegatedResultCorrelation ? { delegatedResultCorrelation: options.delegatedResultCorrelation } : {}) };
|
|
231
192
|
await lifecycle.emit({ runId: runId, input: {
|
|
232
193
|
event: 'agent',
|
|
233
194
|
data: { type: 'tool_use', id: toolUseId, name: toolId, input },
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DelegatedResultCorrelation } from '../delegated-result-correlation.js';
|
|
1
2
|
import type { Express } from 'express';
|
|
2
3
|
import { type Principal, type ToolRegistry } from '@jini-ai/core';
|
|
3
4
|
import { type RunLifecycle, type ToolExecutionResult, type ToolExecutor } from '../index.js';
|
|
@@ -49,6 +50,8 @@ export interface DelegatedToolsInternalErrorContext {
|
|
|
49
50
|
readonly status?: 'timed-out' | 'cancelled' | 'failed';
|
|
50
51
|
}
|
|
51
52
|
export interface DelegatedToolsHttpDeps {
|
|
53
|
+
/** The same negotiated digest store supplied to the run lifecycle and agent executor. */
|
|
54
|
+
readonly delegatedResultCorrelation?: DelegatedResultCorrelation;
|
|
52
55
|
readonly lifecycle: RunLifecycle;
|
|
53
56
|
readonly toolExecutor: ToolExecutor;
|
|
54
57
|
/** Host-owned: resolves the `Principal` a given delegated-tool-call request executes as. Mandatory — see module doc; there is no safe default identity this package could assume on a host's behalf. */
|
|
@@ -203,7 +203,7 @@ export const delegatedToolExecuteRoute = defineJsonRoute({
|
|
|
203
203
|
const toolExecutor = input.requireReadOnly === true
|
|
204
204
|
? readOnlyTools.withReadOnlyToolConstraint({ inner: deps.toolExecutor, registry: deps.toolRegistry, messages: readOnlyTools.defaultDaemonMessages.readOnly, idGenerator: { newId: randomUUID } })
|
|
205
205
|
: deps.toolExecutor;
|
|
206
|
-
const bridge = createDelegatedToolBridge({ lifecycle: deps.lifecycle, toolExecutor });
|
|
206
|
+
const bridge = createDelegatedToolBridge({ lifecycle: deps.lifecycle, toolExecutor }, deps);
|
|
207
207
|
try {
|
|
208
208
|
// `signal` carries the caller's HTTP connection dropping — see `mountJsonRoute`
|
|
209
209
|
// (`adapter.ts`). The bridge already combines it with the run's own cancellation
|
package/dist/index.d.ts
CHANGED
|
@@ -93,4 +93,6 @@ export * from './run/index.js';
|
|
|
93
93
|
export * from './continuation/index.js';
|
|
94
94
|
export * from './routines/index.js';
|
|
95
95
|
export * from './attachment-content.js';
|
|
96
|
+
export * from './delegated-result-correlation.js';
|
|
97
|
+
export type { StdoutJournalBlobPort } from './tool-result-diagnostics.js';
|
|
96
98
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
package/dist/run-lifecycle.d.ts
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `RunLifecycle` — start/stream/cancel/resume a run keyed on an opaque
|
|
3
|
+
* `contextRef` (extraction-plan §2.1: runs key on an opaque `contextRef`,
|
|
4
|
+
* never a product-record identifier), generalized from OD's `design.runs` service
|
|
5
|
+
* (`apps/daemon/src/runtimes/runs.ts` + the terminal-decision half of
|
|
6
|
+
* `apps/daemon/src/runtimes/start-chat-run.ts`, `arch/server-startserver-endgame`
|
|
7
|
+
* branch — see `archived provenance ledger` for exact line citations).
|
|
8
|
+
*
|
|
9
|
+
* Scope boundary: this module owns the run *state machine* and its event
|
|
10
|
+
* log — create, transition, emit, replay, cancellation-intent propagation,
|
|
11
|
+
* idempotent start/finish, resume. It does not spawn or signal a subprocess;
|
|
12
|
+
* that is `@jini-ai/agent-runtime`'s job (extraction-plan task 7). A driver
|
|
13
|
+
* (today: this package's tests; later: agent-runtime) calls `emit()` for
|
|
14
|
+
* agent/stdout/stderr/error events, observes cancellation via
|
|
15
|
+
* `onCancelRequested`, and calls `finish()` once it knows the real outcome.
|
|
16
|
+
*/
|
|
17
|
+
import type { DelegatedResultCorrelation } from './delegated-result-correlation.js';
|
|
1
18
|
import type { RunAgentPayload, RunCancelRequest, RunChunkPayload, RunErrorPayload, RunProtocolEvent, RunStartPayload, RunStatus } from '@jini-ai/protocol';
|
|
2
19
|
import type { EventLog, EventLogEntry, EventLogReplayResult } from './event-log.js';
|
|
3
20
|
import type { InactivityWatchdog, TerminalRunOutcome } from './close-status.js';
|
|
@@ -276,6 +293,8 @@ export declare function finishStreamSubscription({ record, subscriber, terminal
|
|
|
276
293
|
readonly terminal: boolean;
|
|
277
294
|
}): StreamSubscribeResult;
|
|
278
295
|
export interface CreateRunLifecycleInput {
|
|
296
|
+
/** Share the negotiated bridge/executor digest store so run eviction also releases proofs. */
|
|
297
|
+
readonly delegatedResultCorrelation?: DelegatedResultCorrelation;
|
|
279
298
|
readonly eventLog: EventLog;
|
|
280
299
|
/** Host-owned sink for asynchronous lifecycle failures that cannot be returned to a caller. Defaults to `console.error`. */
|
|
281
300
|
readonly onInternalError?: (context: RunLifecycleInternalErrorContext) => void;
|
|
@@ -319,5 +338,5 @@ export interface RunLifecycleInternalErrorContext {
|
|
|
319
338
|
* @complexity Per-call complexities documented on each method; the registry itself is a `Map` keyed by `runId` (O(1) lookup). Memory is bounded: non-terminal runs are O(n) in concurrently-live runs, and terminal runs are capped at `maxTerminalRuns` (each additionally bounded in retention time by `terminalRetentionMs`) rather than growing for the life of the process.
|
|
320
339
|
* @overallScore 100/100
|
|
321
340
|
*/
|
|
322
|
-
export declare function createRunLifecycle(requiredArgs: Pick<CreateRunLifecycleInput, "eventLog">, optionalArgs?: Pick<CreateRunLifecycleInput, "onInternalError" | "terminalRetentionMs" | "maxTerminalRuns" | "slowRunThresholdMs" | "dropEventsOnEviction">): RunLifecycle;
|
|
341
|
+
export declare function createRunLifecycle(requiredArgs: Pick<CreateRunLifecycleInput, "eventLog">, optionalArgs?: Pick<CreateRunLifecycleInput, "onInternalError" | "terminalRetentionMs" | "maxTerminalRuns" | "slowRunThresholdMs" | "dropEventsOnEviction" | "delegatedResultCorrelation">): RunLifecycle;
|
|
323
342
|
//# sourceMappingURL=run-lifecycle.d.ts.map
|
package/dist/run-lifecycle.js
CHANGED
|
@@ -1,19 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `RunLifecycle` — start/stream/cancel/resume a run keyed on an opaque
|
|
3
|
-
* `contextRef` (extraction-plan §2.1: runs key on an opaque `contextRef`,
|
|
4
|
-
* never a product-record identifier), generalized from OD's `design.runs` service
|
|
5
|
-
* (`apps/daemon/src/runtimes/runs.ts` + the terminal-decision half of
|
|
6
|
-
* `apps/daemon/src/runtimes/start-chat-run.ts`, `arch/server-startserver-endgame`
|
|
7
|
-
* branch — see `archived provenance ledger` for exact line citations).
|
|
8
|
-
*
|
|
9
|
-
* Scope boundary: this module owns the run *state machine* and its event
|
|
10
|
-
* log — create, transition, emit, replay, cancellation-intent propagation,
|
|
11
|
-
* idempotent start/finish, resume. It does not spawn or signal a subprocess;
|
|
12
|
-
* that is `@jini-ai/agent-runtime`'s job (extraction-plan task 7). A driver
|
|
13
|
-
* (today: this package's tests; later: agent-runtime) calls `emit()` for
|
|
14
|
-
* agent/stdout/stderr/error events, observes cancellation via
|
|
15
|
-
* `onCancelRequested`, and calls `finish()` once it knows the real outcome.
|
|
16
|
-
*/
|
|
17
1
|
import { randomUUID } from 'node:crypto';
|
|
18
2
|
import { RUN_PROTOCOL_VERSION, isTerminalRunState } from '@jini-ai/protocol';
|
|
19
3
|
import { createInactivityWatchdog } from './close-status.js';
|
|
@@ -367,6 +351,7 @@ export function createRunLifecycle(requiredArgs, optionalArgs = {}) {
|
|
|
367
351
|
}
|
|
368
352
|
catch (error) {
|
|
369
353
|
runs.delete(runId);
|
|
354
|
+
input.delegatedResultCorrelation?.clearRun({ runId });
|
|
370
355
|
clearIdempotencyIndexEntryIfMatching({ idempotencyIndex: idempotencyIndex, idempotencyKey: startInput.idempotencyKey, runId: runId });
|
|
371
356
|
throw error;
|
|
372
357
|
}
|
|
@@ -418,6 +403,7 @@ export function createRunLifecycle(requiredArgs, optionalArgs = {}) {
|
|
|
418
403
|
return;
|
|
419
404
|
record.retentionTimer = undefined;
|
|
420
405
|
runs.delete(runId);
|
|
406
|
+
input.delegatedResultCorrelation?.clearRun({ runId });
|
|
421
407
|
clearIdempotencyIndexEntryIfMatching({ idempotencyIndex: idempotencyIndex, idempotencyKey: record.idempotencyKey, runId: runId });
|
|
422
408
|
if (input.dropEventsOnEviction === true)
|
|
423
409
|
void dropEvictedRunEvents(runId);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Audit writers must store exact chunks in blobs and retain resolvable references separately.
|
|
2
|
+
* This port deliberately cannot masquerade as the inline RunByteJournal contract. */
|
|
3
|
+
export interface StdoutJournalBlobPort {
|
|
4
|
+
/** Buffer chunks remain bytes, including UTF-8 characters split at OS read boundaries. */
|
|
5
|
+
record(input: {
|
|
6
|
+
runId: string;
|
|
7
|
+
chunk: string | Uint8Array;
|
|
8
|
+
}, options?: Record<string, never>): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
/** Projects one complete diagnostic frame; ordinary non-JSON output remains byte-identical. */
|
|
11
|
+
export declare function projectDiagnosticFrame({ line }: {
|
|
12
|
+
line: string;
|
|
13
|
+
}, { maxFrameChars }?: {
|
|
14
|
+
maxFrameChars?: number;
|
|
15
|
+
}): string;
|
|
16
|
+
/** Bounded JSONL framing: split chunks are held until complete, tails flush at close.
|
|
17
|
+
* Oversized frames produce an explicit notice and are discarded through their newline.
|
|
18
|
+
* @complexity O(n) total input characters; O(maxFrameChars) retained carry, no per-chunk joins. */
|
|
19
|
+
export declare function createToolResultDiagnosticProjection(_input: Record<string, never>, { maxFrameChars }?: {
|
|
20
|
+
maxFrameChars?: number;
|
|
21
|
+
}): {
|
|
22
|
+
feed({ chunk }: {
|
|
23
|
+
chunk: string;
|
|
24
|
+
}, _options?: {}): string;
|
|
25
|
+
flush(_input: Record<string, never>, _options?: {}): string;
|
|
26
|
+
};
|
|
27
|
+
//# sourceMappingURL=tool-result-diagnostics.d.ts.map
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
function imageSummary(value) {
|
|
2
|
+
if (value.type !== 'image')
|
|
3
|
+
return undefined;
|
|
4
|
+
if (typeof value.mimeType === 'string' && typeof value.data === 'string') {
|
|
5
|
+
return { ...value, data: `[image ${value.mimeType}; ${value.data.length} base64 characters]` };
|
|
6
|
+
}
|
|
7
|
+
const source = value.source;
|
|
8
|
+
if (typeof source !== 'object' || source === null)
|
|
9
|
+
return undefined;
|
|
10
|
+
const image = source;
|
|
11
|
+
if (image.type !== 'base64' || typeof image.data !== 'string' || typeof image.media_type !== 'string')
|
|
12
|
+
return undefined;
|
|
13
|
+
return { ...value, source: { ...image, data: `[image ${image.media_type}; ${image.data.length} base64 characters]` } };
|
|
14
|
+
}
|
|
15
|
+
/** Redacts only recognized image fields, including JSON-encoded tool-result content.
|
|
16
|
+
* @complexity O(n) frame bytes, bounded by the framing limit before parsing. */
|
|
17
|
+
function summarizeImages(_key, value) {
|
|
18
|
+
if (_key === 'content' && typeof value === 'string')
|
|
19
|
+
return projectDiagnosticFrame({ line: value });
|
|
20
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
21
|
+
return value;
|
|
22
|
+
return imageSummary(value) ?? value;
|
|
23
|
+
}
|
|
24
|
+
/** Projects one complete diagnostic frame; ordinary non-JSON output remains byte-identical. */
|
|
25
|
+
export function projectDiagnosticFrame({ line }, { maxFrameChars = 16 * 1024 * 1024 } = {}) {
|
|
26
|
+
if (line.length > maxFrameChars)
|
|
27
|
+
return '[diagnostic frame exceeded image projection limit]';
|
|
28
|
+
try {
|
|
29
|
+
return JSON.stringify(JSON.parse(line), summarizeImages);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return line;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Bounded JSONL framing: split chunks are held until complete, tails flush at close.
|
|
36
|
+
* Oversized frames produce an explicit notice and are discarded through their newline.
|
|
37
|
+
* @complexity O(n) total input characters; O(maxFrameChars) retained carry, no per-chunk joins. */
|
|
38
|
+
export function createToolResultDiagnosticProjection(_input, { maxFrameChars = 16 * 1024 * 1024 } = {}) {
|
|
39
|
+
if (!Number.isSafeInteger(maxFrameChars) || maxFrameChars < 1)
|
|
40
|
+
throw new Error('diagnostic frame limit must be a positive integer');
|
|
41
|
+
let parts = [];
|
|
42
|
+
let size = 0;
|
|
43
|
+
let dropping = false;
|
|
44
|
+
function append(part) {
|
|
45
|
+
if (dropping || part.length === 0)
|
|
46
|
+
return;
|
|
47
|
+
size += part.length;
|
|
48
|
+
if (size > maxFrameChars) {
|
|
49
|
+
parts = [];
|
|
50
|
+
dropping = true;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
parts.push(part);
|
|
54
|
+
}
|
|
55
|
+
function finish() {
|
|
56
|
+
const line = dropping ? '[diagnostic frame exceeded image projection limit]' : projectDiagnosticFrame({ line: parts.join('') }, { maxFrameChars });
|
|
57
|
+
parts = [];
|
|
58
|
+
size = 0;
|
|
59
|
+
dropping = false;
|
|
60
|
+
return line;
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
feed({ chunk }, _options = {}) {
|
|
64
|
+
const completed = [];
|
|
65
|
+
let start = 0;
|
|
66
|
+
for (let end = chunk.indexOf('\n'); end !== -1; end = chunk.indexOf('\n', start)) {
|
|
67
|
+
append(chunk.slice(start, end));
|
|
68
|
+
completed.push(finish(), '\n');
|
|
69
|
+
start = end + 1;
|
|
70
|
+
}
|
|
71
|
+
append(chunk.slice(start));
|
|
72
|
+
return completed.join('');
|
|
73
|
+
},
|
|
74
|
+
flush(_input, _options = {}) { return size > 0 || dropping ? finish() : ''; },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=tool-result-diagnostics.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ToolExecutionResult } from './tool-executor.js';
|
|
1
2
|
/**
|
|
2
3
|
* Extracts UI-renderable media blocks from a tool handler's raw MCP-style content envelope
|
|
3
4
|
* (`{ content: [...] }`), for `delegated-tool-bridge.ts` and `agent-executor.ts` to attach to the
|
|
@@ -11,18 +12,9 @@
|
|
|
11
12
|
* and is deliberately narrower: it recognizes exactly one block type today (`image`), and has no
|
|
12
13
|
* withhold-from-model concept of its own.
|
|
13
14
|
*
|
|
14
|
-
* The
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* blocks from `content` before the surfaces pass ran, because `splitToolResultSurfaces`'s whitelist
|
|
18
|
-
* used to recognize only `text`, and an image block left in place would have been swept into
|
|
19
|
-
* `surfaces` and withheld from the model. Now that `image` is itself one of
|
|
20
|
-
* `tool-result-surfaces.ts`'s `MODEL_VISIBLE_BLOCK_TYPES` (a deliberate addition — see that
|
|
21
|
-
* constant's own doc), the surfaces pass classifies an image block correctly on its own, so there is
|
|
22
|
-
* no misclassification left to prevent by removing it first. This module's `remainder` field is
|
|
23
|
-
* therefore no longer read by that caller at all (kept for this module's own tests and any future
|
|
24
|
-
* caller that wants a de-imaged copy); only `media` is used, to populate the `tool_result` event's
|
|
25
|
-
* sibling `media` field for a UI that wants to render inline without re-parsing `content`.
|
|
15
|
+
* The surface whitelist runs on the original output first. Its model-visible result still
|
|
16
|
+
* contains images for MCP/model delivery. Only the event projection below serializes this
|
|
17
|
+
* module's de-imaged remainder, alongside one `media` array for rendering.
|
|
26
18
|
*
|
|
27
19
|
* ## Why only `image`, and why blocks are silently skipped rather than reported
|
|
28
20
|
*
|
|
@@ -67,8 +59,7 @@ export interface ExtractResultMedia {
|
|
|
67
59
|
}
|
|
68
60
|
/**
|
|
69
61
|
* Splits a tool handler's raw return value into its recognized media blocks and the remainder,
|
|
70
|
-
*
|
|
71
|
-
* matters).
|
|
62
|
+
* without modifying the model-facing output. Run the surface whitelist on the original first.
|
|
72
63
|
*
|
|
73
64
|
* @param output - A tool handler's raw return value (`ToolExecutionResult.output` when
|
|
74
65
|
* `status === 'completed'`). Non-envelope values (the overwhelming majority of tools, which return
|
|
@@ -78,4 +69,26 @@ export interface ExtractResultMedia {
|
|
|
78
69
|
export declare function extractResultMedia({ output }: {
|
|
79
70
|
readonly output: unknown;
|
|
80
71
|
}): ExtractResultMedia;
|
|
72
|
+
/** Converts arbitrary registered-tool output into the string-bearing run protocol. */
|
|
73
|
+
export declare function serializeDelegatedToolOutput({ output }: {
|
|
74
|
+
readonly output: unknown;
|
|
75
|
+
}): string;
|
|
76
|
+
/**
|
|
77
|
+
* Maps a `ToolExecutionResult`'s status to the string a delegated-tool caller (this bridge, or
|
|
78
|
+
* gap 3's stdin-tool-result injector in `agent-executor.ts`) reports back as the tool's visible
|
|
79
|
+
* output/failure reason. Exported so both real callers share one mapping rather than drifting.
|
|
80
|
+
*/
|
|
81
|
+
export declare function resultContent({ result }: {
|
|
82
|
+
readonly result: ToolExecutionResult;
|
|
83
|
+
}): string;
|
|
84
|
+
/** Projects a model-safe result into the saved event: pixels occur only in `media`.
|
|
85
|
+
* Uses the existing serializer and status mapping; callers keep the original for the model.
|
|
86
|
+
* @complexity O(n) output bytes, with O(n) transient serialization space. */
|
|
87
|
+
export declare function projectToolResultEvent({ result }: {
|
|
88
|
+
result: ToolExecutionResult;
|
|
89
|
+
}, _options?: {}): {
|
|
90
|
+
content: string;
|
|
91
|
+
isError?: true;
|
|
92
|
+
media?: readonly ToolResultMediaBlock[];
|
|
93
|
+
};
|
|
81
94
|
//# sourceMappingURL=tool-result-media.d.ts.map
|
|
@@ -1,39 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Extracts UI-renderable media blocks from a tool handler's raw MCP-style content envelope
|
|
3
|
-
* (`{ content: [...] }`), for `delegated-tool-bridge.ts` and `agent-executor.ts` to attach to the
|
|
4
|
-
* `tool_result` wire event's new `media` field (`@jini-ai/protocol`'s `events.ts`).
|
|
5
|
-
*
|
|
6
|
-
* ## Why this is not `tool-result-surfaces.ts`
|
|
7
|
-
*
|
|
8
|
-
* That module's whitelist governs a SECURITY property: which blocks may reach the model versus
|
|
9
|
-
* which are withheld for a human-only UI resource (MCP-UI, still deferred). This module answers a
|
|
10
|
-
* different question — "does this block carry displayable media a chat pane can render inline" —
|
|
11
|
-
* and is deliberately narrower: it recognizes exactly one block type today (`image`), and has no
|
|
12
|
-
* withhold-from-model concept of its own.
|
|
13
|
-
*
|
|
14
|
-
* The two DO interact, though not by pipelining: `delegated-tool-bridge.ts`'s `execute()` calls both
|
|
15
|
-
* of them against the SAME raw `executed.output`, not this module's `remainder` into
|
|
16
|
-
* `splitToolResultSurfaces`. That used to be pipelined — this module removed recognized image
|
|
17
|
-
* blocks from `content` before the surfaces pass ran, because `splitToolResultSurfaces`'s whitelist
|
|
18
|
-
* used to recognize only `text`, and an image block left in place would have been swept into
|
|
19
|
-
* `surfaces` and withheld from the model. Now that `image` is itself one of
|
|
20
|
-
* `tool-result-surfaces.ts`'s `MODEL_VISIBLE_BLOCK_TYPES` (a deliberate addition — see that
|
|
21
|
-
* constant's own doc), the surfaces pass classifies an image block correctly on its own, so there is
|
|
22
|
-
* no misclassification left to prevent by removing it first. This module's `remainder` field is
|
|
23
|
-
* therefore no longer read by that caller at all (kept for this module's own tests and any future
|
|
24
|
-
* caller that wants a de-imaged copy); only `media` is used, to populate the `tool_result` event's
|
|
25
|
-
* sibling `media` field for a UI that wants to render inline without re-parsing `content`.
|
|
26
|
-
*
|
|
27
|
-
* ## Why only `image`, and why blocks are silently skipped rather than reported
|
|
28
|
-
*
|
|
29
|
-
* `text` blocks already reach the model (and the UI, via `content`) through the existing flattened
|
|
30
|
-
* string — duplicating them into `media` would be redundant, not additive, so this module leaves
|
|
31
|
-
* them in `remainder` and never collects them. A block claiming `type: 'image'` without a string
|
|
32
|
-
* `mimeType`/`data` is dropped rather than surfaced as a malformed-block error: a handler's return
|
|
33
|
-
* value is untrusted input to this module (it may come from an external MCP server), and the
|
|
34
|
-
* contract is "render what is well-formed, ignore what isn't" — the same fail-quiet posture
|
|
35
|
-
* `serializeDelegatedToolOutput` already takes on a non-serializable output.
|
|
36
|
-
*/
|
|
1
|
+
import { batchOutcomeFor } from '@jini-ai/core';
|
|
37
2
|
function isRecord(value) {
|
|
38
3
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
39
4
|
}
|
|
@@ -50,8 +15,7 @@ function asImageBlock(block) {
|
|
|
50
15
|
}
|
|
51
16
|
/**
|
|
52
17
|
* Splits a tool handler's raw return value into its recognized media blocks and the remainder,
|
|
53
|
-
*
|
|
54
|
-
* matters).
|
|
18
|
+
* without modifying the model-facing output. Run the surface whitelist on the original first.
|
|
55
19
|
*
|
|
56
20
|
* @param output - A tool handler's raw return value (`ToolExecutionResult.output` when
|
|
57
21
|
* `status === 'completed'`). Non-envelope values (the overwhelming majority of tools, which return
|
|
@@ -77,4 +41,53 @@ export function extractResultMedia({ output }) {
|
|
|
77
41
|
return { remainder: output, media: [] };
|
|
78
42
|
return { remainder: { ...output, content: kept }, media };
|
|
79
43
|
}
|
|
44
|
+
/** Converts arbitrary registered-tool output into the string-bearing run protocol. */
|
|
45
|
+
export function serializeDelegatedToolOutput({ output }) {
|
|
46
|
+
if (typeof output === 'string')
|
|
47
|
+
return output;
|
|
48
|
+
if (output === undefined)
|
|
49
|
+
return '';
|
|
50
|
+
try {
|
|
51
|
+
const serialized = JSON.stringify(output);
|
|
52
|
+
return serialized === undefined ? String(output) : serialized;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return String(output);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Maps a `ToolExecutionResult`'s status to the string a delegated-tool caller (this bridge, or
|
|
60
|
+
* gap 3's stdin-tool-result injector in `agent-executor.ts`) reports back as the tool's visible
|
|
61
|
+
* output/failure reason. Exported so both real callers share one mapping rather than drifting.
|
|
62
|
+
*/
|
|
63
|
+
export function resultContent({ result }) {
|
|
64
|
+
switch (result.status) {
|
|
65
|
+
case 'completed':
|
|
66
|
+
return serializeDelegatedToolOutput({ output: result.output });
|
|
67
|
+
case 'denied':
|
|
68
|
+
return 'Tool execution denied by policy.';
|
|
69
|
+
case 'confirmation-denied':
|
|
70
|
+
return 'Tool execution denied during confirmation.';
|
|
71
|
+
case 'timed-out':
|
|
72
|
+
return 'Tool execution timed out.';
|
|
73
|
+
case 'cancelled':
|
|
74
|
+
return 'Tool execution cancelled.';
|
|
75
|
+
case 'failed':
|
|
76
|
+
return result.error ?? 'Tool execution failed.';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Projects a model-safe result into the saved event: pixels occur only in `media`.
|
|
80
|
+
* Uses the existing serializer and status mapping; callers keep the original for the model.
|
|
81
|
+
* @complexity O(n) output bytes, with O(n) transient serialization space. */
|
|
82
|
+
export function projectToolResultEvent({ result }, _options = {}) {
|
|
83
|
+
const { remainder, media } = extractResultMedia({ output: result.output });
|
|
84
|
+
const isError = result.status !== 'completed'
|
|
85
|
+
|| (batchOutcomeFor({ output: result.output }) ?? 'completed') !== 'completed'
|
|
86
|
+
|| (isRecord(result.output) && result.output.isError === true);
|
|
87
|
+
return {
|
|
88
|
+
content: resultContent({ result: { ...result, output: remainder } }),
|
|
89
|
+
...(isError ? { isError: true } : {}),
|
|
90
|
+
...(media.length > 0 ? { media } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
80
93
|
//# sourceMappingURL=tool-result-media.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/daemon",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.10",
|
|
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": {
|
|
@@ -139,29 +139,29 @@
|
|
|
139
139
|
"@types/pg": "8.23.1",
|
|
140
140
|
"express": "^4.21.0",
|
|
141
141
|
"@types/express": "^4.17.21",
|
|
142
|
-
"@jini-ai/
|
|
143
|
-
"@jini-ai/
|
|
144
|
-
"@jini-ai/
|
|
145
|
-
"@jini-ai/core": "^0.
|
|
146
|
-
"@jini-ai/
|
|
147
|
-
"@jini-ai/
|
|
148
|
-
"@jini-ai/
|
|
142
|
+
"@jini-ai/http-kit": "0.6.10",
|
|
143
|
+
"@jini-ai/db": "0.6.10",
|
|
144
|
+
"@jini-ai/chat": "0.6.10",
|
|
145
|
+
"@jini-ai/core": "^0.6.10",
|
|
146
|
+
"@jini-ai/agent-runtime": "^0.6.10",
|
|
147
|
+
"@jini-ai/protocol": "^0.6.10",
|
|
148
|
+
"@jini-ai/platform": "^0.6.10"
|
|
149
149
|
},
|
|
150
150
|
"dependencies": {
|
|
151
|
-
"@jini-ai/
|
|
152
|
-
"@jini-ai/
|
|
151
|
+
"@jini-ai/protocol": "^0.6.10",
|
|
152
|
+
"@jini-ai/core": "^0.6.10"
|
|
153
153
|
},
|
|
154
154
|
"peerDependencies": {
|
|
155
155
|
"node-pty": "^1.1.0",
|
|
156
|
-
"@jini-ai/db": "^0.
|
|
156
|
+
"@jini-ai/db": "^0.6.10",
|
|
157
157
|
"kysely": "^0.29.6",
|
|
158
158
|
"better-sqlite3": "^13.0.0",
|
|
159
159
|
"@electric-sql/pglite": "0.5.8",
|
|
160
160
|
"pg": "^8.23.0",
|
|
161
|
-
"@jini-ai/http-kit": "^0.
|
|
161
|
+
"@jini-ai/http-kit": "^0.6.10",
|
|
162
162
|
"express": "^4.21.0 || ^5.0.0",
|
|
163
|
-
"@jini-ai/agent-runtime": "^0.
|
|
164
|
-
"@jini-ai/platform": "^0.
|
|
163
|
+
"@jini-ai/agent-runtime": "^0.6.10",
|
|
164
|
+
"@jini-ai/platform": "^0.6.10"
|
|
165
165
|
},
|
|
166
166
|
"peerDependenciesMeta": {
|
|
167
167
|
"node-pty": {
|