@jini-ai/daemon 0.6.8 → 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 +148 -0
- package/dist/agent-executor/antigravity-permissions.d.ts +11 -0
- package/dist/agent-executor/antigravity-permissions.js +64 -0
- package/dist/agent-executor/contracts.d.ts +37 -6
- package/dist/agent-executor/event-translation.d.ts +6 -1
- package/dist/agent-executor/event-translation.js +3 -1
- package/dist/agent-executor/events.d.ts +5 -0
- package/dist/agent-executor/events.js +143 -87
- package/dist/agent-executor/launch.d.ts +5 -3
- package/dist/agent-executor/launch.js +315 -217
- package/dist/agent-executor/mcp-bridge-probe.js +51 -35
- package/dist/agent-executor/mcp-bridge.d.ts +18 -8
- package/dist/agent-executor/mcp-bridge.js +105 -65
- package/dist/agent-executor/run-output.d.ts +20 -0
- package/dist/agent-executor/run-output.js +80 -0
- package/dist/attachment-content.js +18 -15
- package/dist/core/read-only-tools.d.ts +15 -0
- package/dist/core/read-only-tools.js +29 -1
- package/dist/core/tool-audit.d.ts +3 -1
- package/dist/core/tool-audit.js +35 -22
- package/dist/core/tool-gaps.d.ts +60 -0
- package/dist/core/tool-gaps.js +199 -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 +6 -14
- package/dist/delegated-tool-bridge.js +155 -155
- package/dist/event-log.d.ts +45 -2
- package/dist/event-log.js +83 -4
- package/dist/http/attachments.d.ts +26 -1
- package/dist/http/attachments.js +129 -65
- package/dist/http/connectors.d.ts +1 -32
- package/dist/http/connectors.js +13 -68
- package/dist/http/delegated-tools.d.ts +8 -1
- package/dist/http/delegated-tools.js +30 -30
- package/dist/http/host-tools.d.ts +1 -0
- package/dist/http/host-tools.js +64 -59
- package/dist/http/index.d.ts +2 -2
- package/dist/http/index.js +1 -1
- package/dist/http/media.js +48 -69
- package/dist/http/memory.js +45 -36
- package/dist/http/model-proxy-fields.d.ts +23 -0
- package/dist/http/model-proxy-fields.js +29 -0
- package/dist/http/model-proxy.js +193 -395
- package/dist/http/research-tavily.adapter.js +72 -49
- package/dist/http/research.js +33 -23
- package/dist/http/resource-request.d.ts +37 -0
- package/dist/http/resource-request.js +59 -0
- package/dist/http/routines.js +49 -90
- package/dist/http/runs.d.ts +7 -5
- package/dist/http/runs.js +21 -7
- package/dist/http/terminals.js +23 -48
- package/dist/http/tool-catalog.d.ts +5 -0
- package/dist/http/xai.d.ts +11 -1
- package/dist/http/xai.js +125 -124
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/legacy-data-migration.js +41 -24
- package/dist/run/core/retry.js +51 -35
- package/dist/run/diagnostics/diagnostics.js +5 -17
- package/dist/run-credentials.js +49 -27
- package/dist/run-lifecycle.d.ts +31 -2
- package/dist/run-lifecycle.js +49 -42
- package/dist/store/agent-sessions/sqlite.d.ts +6 -0
- package/dist/store/agent-sessions/sqlite.js +6 -4
- package/dist/surface-exchanges.js +2 -1
- package/dist/terminal-session.d.ts +1 -1
- package/dist/terminal-session.js +26 -33
- package/dist/tool-audit.d.ts +1 -0
- package/dist/tool-audit.js +1 -0
- package/dist/tool-executor.d.ts +24 -3
- package/dist/tool-executor.js +276 -139
- package/dist/tool-recovery/recovery.js +64 -35
- package/dist/tool-recovery/stack.d.ts +2 -1
- package/dist/tool-recovery/stack.js +1 -1
- 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
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* `DelegatedToolBridge` — the execution path for agents/protocols that ask
|
|
3
|
+
* Jini to run a registered tool on their behalf. It is deliberately separate
|
|
4
|
+
* from ACP's `session/request_permission`: ACP authorizes an agent's *native*
|
|
5
|
+
* tool loop, whereas this bridge invokes Jini's `ToolExecutor` and therefore
|
|
6
|
+
* enforces Jini's registry policy, confirmation, timeout, cancellation, and
|
|
7
|
+
* audit trail before any registered handler runs.
|
|
8
|
+
*/
|
|
9
|
+
import {} from '@jini-ai/core';
|
|
10
|
+
import { projectToolResultEvent } from './tool-result-media.js';
|
|
2
11
|
import { splitToolResultSurfaces } from './tool-result-surfaces.js';
|
|
3
12
|
/**
|
|
4
13
|
* Run-protocol `type` for a human-only surface withheld from a tool result.
|
|
@@ -29,40 +38,140 @@ function correlationFor(channel, toolUseId) {
|
|
|
29
38
|
function errorMessage(error) {
|
|
30
39
|
return error instanceof Error ? error.message : String(error);
|
|
31
40
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
export { serializeDelegatedToolOutput, resultContent } from './tool-result-media.js';
|
|
42
|
+
/**
|
|
43
|
+
* Builds the abort controller handed to `ToolExecutor`, aborted by either a run cancellation or the
|
|
44
|
+
* invocation's own transport signal. `dispose()` detaches both listeners once the call settles.
|
|
45
|
+
*/
|
|
46
|
+
function linkCancellation({ lifecycle, runId }, { signal }) {
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
const unsubscribeCancel = lifecycle.onCancelRequested({ runId: runId, listener: () => controller.abort() });
|
|
49
|
+
const abortFromTransport = () => controller.abort();
|
|
50
|
+
if (signal?.aborted)
|
|
51
|
+
controller.abort();
|
|
52
|
+
else
|
|
53
|
+
signal?.addEventListener('abort', abortFromTransport, { once: true });
|
|
54
|
+
return {
|
|
55
|
+
controller,
|
|
56
|
+
dispose: () => {
|
|
57
|
+
unsubscribeCancel();
|
|
58
|
+
signal?.removeEventListener('abort', abortFromTransport);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
45
61
|
}
|
|
46
62
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
63
|
+
* The seam a handler needs in order to show something it then WAITS on.
|
|
64
|
+
*
|
|
65
|
+
* Surfaces normally ride out in the return value and are split out below — which is useless to a
|
|
66
|
+
* handler that cannot return until the human has answered the very thing it wants to display.
|
|
67
|
+
* Emitting through here reaches the same run event stream, so a message pushed mid-call is
|
|
68
|
+
* indistinguishable to a renderer from one carried by the result.
|
|
69
|
+
*
|
|
70
|
+
* **Channel-neutral by construction.** The handler names its own channel, and this only injects
|
|
71
|
+
* correlation. That is what lets one seam drive `mcp-ui`, A2UI's multi-turn envelope
|
|
72
|
+
* (`createSurface` → `updateComponents` → …), the protocol's own `surface_request`/
|
|
73
|
+
* `surface_response` pair, or a channel invented later — none of which this file needs to know
|
|
74
|
+
* the shape of, matching `@jini-ai/protocol`'s own posture of typing each channel's body
|
|
75
|
+
* `unknown` and validating where the concrete type is known.
|
|
76
|
+
*
|
|
77
|
+
* Callable any number of times before the call settles; refused after. A handler that stashed
|
|
78
|
+
* this emitter could otherwise paint a surface onto a run whose `tool_result` is already on
|
|
79
|
+
* screen, with no call left to answer it.
|
|
50
80
|
*/
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
81
|
+
function createSurfaceSeam({ lifecycle, runId, toolUseId }) {
|
|
82
|
+
let settled = false;
|
|
83
|
+
// A handler emits a surface mid-call because it is about to WAIT on the human (a form, a card).
|
|
84
|
+
// Nothing reaches the run's event stream while they answer, so the slow-run watchdog would read
|
|
85
|
+
// that silence as a stall and post "Still working…" over an open form — which then reappears
|
|
86
|
+
// once the form is answered. Suspended from the first surface until the call settles (the
|
|
87
|
+
// `finally` below); the watchdog re-arms then, so a genuine stall afterwards is still caught.
|
|
88
|
+
let slowRunSuspended = false;
|
|
89
|
+
const emitSurface = async (emission) => {
|
|
90
|
+
if (settled)
|
|
91
|
+
throw new Error(`emitSurface: tool call ${toolUseId} has already completed`);
|
|
92
|
+
if (!slowRunSuspended) {
|
|
93
|
+
slowRunSuspended = true;
|
|
94
|
+
lifecycle.suspendSlowRunNotice({ runId: runId });
|
|
95
|
+
}
|
|
96
|
+
// Cast because `RunAgentPayload` is a closed union and `channel` is deliberately an open
|
|
97
|
+
// string — a seam that only accepts channels this file already knows about is not a
|
|
98
|
+
// channel-neutral seam. This mirrors `@jini-ai/protocol`'s own posture of typing each
|
|
99
|
+
// channel's BODY `unknown` and validating where the concrete type is known, applied one level
|
|
100
|
+
// up to the channel name itself.
|
|
101
|
+
//
|
|
102
|
+
// Blast radius of a bogus channel is bounded and worth stating: this path is human-only, so
|
|
103
|
+
// the failure mode is an event no renderer subscribes to — nothing rendered. It cannot put
|
|
104
|
+
// anything into model context, which is the property that would actually matter.
|
|
105
|
+
//
|
|
106
|
+
// That bound holds only for an UNKNOWN channel, which is why the spread order below matters.
|
|
107
|
+
// `payload` used to be spread LAST, so a handler could set `type` to a channel this bridge
|
|
108
|
+
// does know — `tool_result`, `mcp-ui` — and set `toolUseId` to a DIFFERENT call's, producing
|
|
109
|
+
// a well-formed forged event correlated against someone else's tool call. The cast is what
|
|
110
|
+
// let it past the closed `RunAgentPayload` union unnoticed. `SurfaceEmission.payload` is
|
|
111
|
+
// documented as "the channel's own fields, merged into the emitted event BESIDE `type`" —
|
|
112
|
+
// beside, not over — so both reserved keys are stripped rather than merely out-ordered.
|
|
113
|
+
// Stripping, not reordering: `correlationFor` returns `{}` for a channel that carries no
|
|
114
|
+
// `toolUseId`, so spread order alone would still let a payload smuggle one onto those.
|
|
115
|
+
const safePayload = { ...emission.payload };
|
|
116
|
+
delete safePayload['type'];
|
|
117
|
+
delete safePayload['toolUseId'];
|
|
118
|
+
const data = {
|
|
119
|
+
...safePayload,
|
|
120
|
+
type: emission.channel,
|
|
121
|
+
...correlationFor(emission.channel, toolUseId),
|
|
122
|
+
};
|
|
123
|
+
await lifecycle.emit({ runId: runId, input: { event: 'agent', data } });
|
|
124
|
+
};
|
|
125
|
+
/** Refuses every later emission: the call has a result. */
|
|
126
|
+
const settle = () => {
|
|
127
|
+
settled = true;
|
|
128
|
+
};
|
|
129
|
+
/** Re-arms the slow-run notice if a surface suspended it — run last, once the call's events are out. */
|
|
130
|
+
const release = () => {
|
|
131
|
+
if (slowRunSuspended)
|
|
132
|
+
lifecycle.resumeSlowRunNotice({ runId: runId });
|
|
133
|
+
};
|
|
134
|
+
return { emitSurface, settle, release };
|
|
135
|
+
}
|
|
136
|
+
/** Splits human-only surfaces out of an executed result, emits them, then the matching `tool_result`. */
|
|
137
|
+
async function emitExecutedResult({ lifecycle, runId, toolUseId, delegatedResultCorrelation }, { executed }) {
|
|
138
|
+
// THE MODEL/HUMAN FORK. A tool call's return value is definitionally what the model
|
|
139
|
+
// receives, so a UI resource left inside it is model-visible context no matter what any
|
|
140
|
+
// downstream layer does — `@jini-ai/mcp`'s `okResult()` JSON.stringifies the whole result
|
|
141
|
+
// into a single text block. This is the only point holding BOTH the raw result and the
|
|
142
|
+
// run's event stream, which is why the split happens here rather than later.
|
|
143
|
+
//
|
|
144
|
+
// Whitelist, not blacklist: only block types known to be model-safe survive into `output`;
|
|
145
|
+
// everything else is withheld and emitted for the human. See `tool-result-surfaces.ts` for
|
|
146
|
+
// why that direction is load-bearing.
|
|
147
|
+
const { modelOutput, surfaces } = splitToolResultSurfaces({ output: executed.output });
|
|
148
|
+
const result = surfaces.length === 0 ? executed : { ...executed, output: modelOutput };
|
|
149
|
+
// Emitted BEFORE `tool_result` so the surface is on screen by the time the transcript shows
|
|
150
|
+
// the call completing — otherwise the human is asked to confirm something not yet visible.
|
|
151
|
+
for (const surface of surfaces) {
|
|
152
|
+
await lifecycle.emit({ runId: runId, input: {
|
|
153
|
+
event: 'agent',
|
|
154
|
+
data: { type: MCP_UI_EVENT_TYPE, toolUseId, resource: surface },
|
|
155
|
+
} });
|
|
65
156
|
}
|
|
157
|
+
// The return keeps model-visible images; only the stored event removes their duplicate.
|
|
158
|
+
const projection = projectToolResultEvent({ result });
|
|
159
|
+
await lifecycle.emit({ runId: runId, input: {
|
|
160
|
+
event: 'agent',
|
|
161
|
+
data: {
|
|
162
|
+
type: 'tool_result',
|
|
163
|
+
toolUseId,
|
|
164
|
+
...projection,
|
|
165
|
+
},
|
|
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
|
+
}
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
function isContentEnvelope(output) {
|
|
174
|
+
return typeof output === 'object' && output !== null && 'content' in output && Array.isArray(output.content);
|
|
66
175
|
}
|
|
67
176
|
/**
|
|
68
177
|
* Creates the transport-neutral bridge used by future ACP-delegate, MCP, or
|
|
@@ -70,138 +179,31 @@ export function resultContent({ result }) {
|
|
|
70
179
|
* concrete protocol calls this bridge after it has decoded and authenticated a
|
|
71
180
|
* delegated tool request.
|
|
72
181
|
*/
|
|
73
|
-
export function createDelegatedToolBridge(
|
|
74
|
-
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;
|
|
75
186
|
async function execute(requiredArgs, optionalArgs = {}) {
|
|
187
|
+
// Merged rather than read from `optionalArgs` alone: a caller may hand `signal` in the first
|
|
188
|
+
// object (the delegated-tools HTTP route does), and it must still be honoured.
|
|
76
189
|
const invocation = { ...requiredArgs, ...optionalArgs };
|
|
77
190
|
const { runId, toolUseId, toolId, principal, input } = invocation;
|
|
191
|
+
const call = { lifecycle, runId, toolUseId, ...(options.delegatedResultCorrelation ? { delegatedResultCorrelation: options.delegatedResultCorrelation } : {}) };
|
|
78
192
|
await lifecycle.emit({ runId: runId, input: {
|
|
79
193
|
event: 'agent',
|
|
80
194
|
data: { type: 'tool_use', id: toolUseId, name: toolId, input },
|
|
81
195
|
} });
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
const abortFromTransport = () => controller.abort();
|
|
85
|
-
if (invocation.signal) {
|
|
86
|
-
if (invocation.signal.aborted)
|
|
87
|
-
controller.abort();
|
|
88
|
-
else
|
|
89
|
-
invocation.signal.addEventListener('abort', abortFromTransport, { once: true });
|
|
90
|
-
}
|
|
196
|
+
const cancellation = linkCancellation(call, invocation);
|
|
197
|
+
const seam = createSurfaceSeam(call);
|
|
91
198
|
const run = { id: runId };
|
|
92
|
-
/**
|
|
93
|
-
* The seam a handler needs in order to show something it then WAITS on.
|
|
94
|
-
*
|
|
95
|
-
* Surfaces normally ride out in the return value and are split out below — which is useless to a
|
|
96
|
-
* handler that cannot return until the human has answered the very thing it wants to display.
|
|
97
|
-
* Emitting through here reaches the same run event stream, so a message pushed mid-call is
|
|
98
|
-
* indistinguishable to a renderer from one carried by the result.
|
|
99
|
-
*
|
|
100
|
-
* **Channel-neutral by construction.** The handler names its own channel, and this only injects
|
|
101
|
-
* correlation. That is what lets one seam drive `mcp-ui`, A2UI's multi-turn envelope
|
|
102
|
-
* (`createSurface` → `updateComponents` → …), the protocol's own `surface_request`/
|
|
103
|
-
* `surface_response` pair, or a channel invented later — none of which this file needs to know
|
|
104
|
-
* the shape of, matching `@jini-ai/protocol`'s own posture of typing each channel's body
|
|
105
|
-
* `unknown` and validating where the concrete type is known.
|
|
106
|
-
*
|
|
107
|
-
* Callable any number of times before the call settles; refused after. A handler that stashed
|
|
108
|
-
* this emitter could otherwise paint a surface onto a run whose `tool_result` is already on
|
|
109
|
-
* screen, with no call left to answer it.
|
|
110
|
-
*/
|
|
111
|
-
let settled = false;
|
|
112
|
-
// A handler emits a surface mid-call because it is about to WAIT on the human (a form, a card).
|
|
113
|
-
// Nothing reaches the run's event stream while they answer, so the slow-run watchdog would read
|
|
114
|
-
// that silence as a stall and post "Still working…" over an open form — which then reappears
|
|
115
|
-
// once the form is answered. Suspended from the first surface until the call settles (the
|
|
116
|
-
// `finally` below); the watchdog re-arms then, so a genuine stall afterwards is still caught.
|
|
117
|
-
let slowRunSuspended = false;
|
|
118
|
-
const emitSurface = async (emission) => {
|
|
119
|
-
if (settled)
|
|
120
|
-
throw new Error(`emitSurface: tool call ${toolUseId} has already completed`);
|
|
121
|
-
if (!slowRunSuspended) {
|
|
122
|
-
slowRunSuspended = true;
|
|
123
|
-
lifecycle.suspendSlowRunNotice({ runId: runId });
|
|
124
|
-
}
|
|
125
|
-
// Cast because `RunAgentPayload` is a closed union and `channel` is deliberately an open
|
|
126
|
-
// string — a seam that only accepts channels this file already knows about is not a
|
|
127
|
-
// channel-neutral seam. This mirrors `@jini-ai/protocol`'s own posture of typing each
|
|
128
|
-
// channel's BODY `unknown` and validating where the concrete type is known, applied one level
|
|
129
|
-
// up to the channel name itself.
|
|
130
|
-
//
|
|
131
|
-
// Blast radius of a bogus channel is bounded and worth stating: this path is human-only, so
|
|
132
|
-
// the failure mode is an event no renderer subscribes to — nothing rendered. It cannot put
|
|
133
|
-
// anything into model context, which is the property that would actually matter.
|
|
134
|
-
//
|
|
135
|
-
// That bound holds only for an UNKNOWN channel, which is why the spread order below matters.
|
|
136
|
-
// `payload` used to be spread LAST, so a handler could set `type` to a channel this bridge
|
|
137
|
-
// does know — `tool_result`, `mcp-ui` — and set `toolUseId` to a DIFFERENT call's, producing
|
|
138
|
-
// a well-formed forged event correlated against someone else's tool call. The cast is what
|
|
139
|
-
// let it past the closed `RunAgentPayload` union unnoticed. `SurfaceEmission.payload` is
|
|
140
|
-
// documented as "the channel's own fields, merged into the emitted event BESIDE `type`" —
|
|
141
|
-
// beside, not over — so both reserved keys are stripped rather than merely out-ordered.
|
|
142
|
-
// Stripping, not reordering: `correlationFor` returns `{}` for a channel that carries no
|
|
143
|
-
// `toolUseId`, so spread order alone would still let a payload smuggle one onto those.
|
|
144
|
-
const safePayload = { ...emission.payload };
|
|
145
|
-
delete safePayload['type'];
|
|
146
|
-
delete safePayload['toolUseId'];
|
|
147
|
-
const data = {
|
|
148
|
-
...safePayload,
|
|
149
|
-
type: emission.channel,
|
|
150
|
-
...correlationFor(emission.channel, toolUseId),
|
|
151
|
-
};
|
|
152
|
-
await lifecycle.emit({ runId: runId, input: { event: 'agent', data } });
|
|
153
|
-
};
|
|
154
199
|
try {
|
|
155
200
|
try {
|
|
156
|
-
const executed = await toolExecutor.execute({ principal: principal, run: run, toolId: toolId, input: input }, { signal: controller.signal, emitSurface: emitSurface });
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
// `media` field the `tool_result` event carries below, for a UI that wants to render one
|
|
160
|
-
// inline without re-parsing `content` (`ToolCard`'s `<img>`, per `assistant_demo_image`'s
|
|
161
|
-
// own doc). This does NOT remove the image block from `executed.output` — `image` is one
|
|
162
|
-
// of `tool-result-surfaces.ts`'s own `MODEL_VISIBLE_BLOCK_TYPES`, so the split below already
|
|
163
|
-
// keeps it in `modelOutput` on its own. An image reaching the model IS the point
|
|
164
|
-
// (`assistant_demo_image`/`admin.capture_screenshot` exist to prove exactly that round
|
|
165
|
-
// trip) — unlike an `mcp-ui` resource, which is genuinely human-only and must never survive
|
|
166
|
-
// into `modelOutput`. (Earlier this extraction fed its own de-imaged `remainder` into the
|
|
167
|
-
// surfaces split below and discarded the image from `output` entirely, on the assumption
|
|
168
|
-
// that the sibling `media` field was the only channel a caller needed — true for a UI
|
|
169
|
-
// reading the run's event stream, false for `@jini-ai/mcp`'s `execute_delegated_tool`, which
|
|
170
|
-
// hands the bridge's RETURN VALUE to the model with no access to that event stream at all.)
|
|
171
|
-
const { media } = extractResultMedia({ output: executed.output });
|
|
172
|
-
// THE MODEL/HUMAN FORK. A tool call's return value is definitionally what the model
|
|
173
|
-
// receives, so a UI resource left inside it is model-visible context no matter what any
|
|
174
|
-
// downstream layer does — `@jini-ai/mcp`'s `okResult()` JSON.stringifies the whole result
|
|
175
|
-
// into a single text block. This is the only point holding BOTH the raw result and the
|
|
176
|
-
// run's event stream, which is why the split happens here rather than later.
|
|
177
|
-
//
|
|
178
|
-
// Whitelist, not blacklist: only block types known to be model-safe survive into `output`;
|
|
179
|
-
// everything else is withheld and emitted for the human. See `tool-result-surfaces.ts` for
|
|
180
|
-
// why that direction is load-bearing.
|
|
181
|
-
const { modelOutput, surfaces } = splitToolResultSurfaces({ output: executed.output });
|
|
182
|
-
const result = surfaces.length === 0 ? executed : { ...executed, output: modelOutput };
|
|
183
|
-
// Emitted BEFORE `tool_result` so the surface is on screen by the time the transcript shows
|
|
184
|
-
// the call completing — otherwise the human is asked to confirm something not yet visible.
|
|
185
|
-
for (const surface of surfaces) {
|
|
186
|
-
await lifecycle.emit({ runId: runId, input: {
|
|
187
|
-
event: 'agent',
|
|
188
|
-
data: { type: MCP_UI_EVENT_TYPE, toolUseId, resource: surface },
|
|
189
|
-
} });
|
|
190
|
-
}
|
|
191
|
-
await lifecycle.emit({ runId: runId, input: {
|
|
192
|
-
event: 'agent',
|
|
193
|
-
data: {
|
|
194
|
-
type: 'tool_result',
|
|
195
|
-
toolUseId,
|
|
196
|
-
content: resultContent({ result }),
|
|
197
|
-
...(result.status === 'completed' ? {} : { isError: true }),
|
|
198
|
-
...(media.length > 0 ? { media } : {}),
|
|
199
|
-
},
|
|
200
|
-
} });
|
|
201
|
-
return result;
|
|
201
|
+
const executed = await toolExecutor.execute({ principal: principal, run: run, toolId: toolId, input: input }, { signal: cancellation.controller.signal, emitSurface: seam.emitSurface });
|
|
202
|
+
seam.settle();
|
|
203
|
+
return await emitExecutedResult(call, { executed });
|
|
202
204
|
}
|
|
203
205
|
catch (error) {
|
|
204
|
-
|
|
206
|
+
seam.settle();
|
|
205
207
|
await lifecycle.emit({ runId: runId, input: {
|
|
206
208
|
event: 'agent',
|
|
207
209
|
data: { type: 'tool_result', toolUseId, content: errorMessage(error), isError: true },
|
|
@@ -210,10 +212,8 @@ export function createDelegatedToolBridge(options) {
|
|
|
210
212
|
}
|
|
211
213
|
}
|
|
212
214
|
finally {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (slowRunSuspended)
|
|
216
|
-
lifecycle.resumeSlowRunNotice({ runId: runId });
|
|
215
|
+
cancellation.dispose();
|
|
216
|
+
seam.release();
|
|
217
217
|
}
|
|
218
218
|
}
|
|
219
219
|
return { execute };
|
package/dist/event-log.d.ts
CHANGED
|
@@ -19,6 +19,26 @@
|
|
|
19
19
|
*/
|
|
20
20
|
export type { EventLog, EventLogAppendInput, EventLogAppendOptions, EventLogEntry, EventLogReplayResult, } from '@jini-ai/protocol';
|
|
21
21
|
import type { EventLog } from '@jini-ai/protocol';
|
|
22
|
+
import { type SchedulerPort } from './scheduler.js';
|
|
23
|
+
/**
|
|
24
|
+
* Default time a terminal run's full payloads stay in memory after its `'end'` entry before they are
|
|
25
|
+
* compacted down to that `'end'` entry alone. A finished run's in-memory log only has to cover a
|
|
26
|
+
* client reconnecting and replaying, which takes minutes; a host that needs the full history long
|
|
27
|
+
* term keeps a durable copy (a product transcript, or `@jini-ai/daemon/store/event-log/sqlite`).
|
|
28
|
+
* Without this, every event of every run since boot stayed on the heap — measured at 118 MB of a
|
|
29
|
+
* 430 MB daemon heap, mostly base64 screenshot tool results (memory investigation 2026-10-21).
|
|
30
|
+
* Each delegated tool result is held twice while retained: the bridge's own `tool_result` and the
|
|
31
|
+
* CLI's MCP-wrapped `tool_result` for the same call. They are not deduplicated here because their
|
|
32
|
+
* ids are uncorrelated (the MCP server mints a fresh `toolUseId` per call) and their content differs
|
|
33
|
+
* in shape, and hosts persist transcripts from this stream; this retention window bounds both.
|
|
34
|
+
*/
|
|
35
|
+
export declare const DEFAULT_TERMINAL_EVENT_RETENTION_MS: number;
|
|
36
|
+
/**
|
|
37
|
+
* Default cap on runs held at all. Matches `RunLifecycle`'s own `DEFAULT_MAX_TERMINAL_RUNS`, so a
|
|
38
|
+
* terminal run record the lifecycle still retains can still replay its `'end'` entry. Only ended
|
|
39
|
+
* runs are ever evicted to honour it — see {@link InMemoryEventLogOptions.maxRuns}.
|
|
40
|
+
*/
|
|
41
|
+
export declare const DEFAULT_MAX_EVENT_LOG_RUNS = 1000;
|
|
22
42
|
export interface InMemoryEventLogOptions {
|
|
23
43
|
/**
|
|
24
44
|
* Maximum entries retained per run before the oldest are evicted. Eviction is opt-in: if
|
|
@@ -30,6 +50,23 @@ export interface InMemoryEventLogOptions {
|
|
|
30
50
|
readonly maxEntriesPerRun?: number;
|
|
31
51
|
/** Host clock; defaults to Date.now to preserve recorded timestamps. */
|
|
32
52
|
readonly now?: () => number;
|
|
53
|
+
/**
|
|
54
|
+
* How long after a run's `'end'` entry its other entries are kept before the run is compacted to
|
|
55
|
+
* that `'end'` entry alone (a replay from `null` then reports `truncated`; a reconnect cursor older
|
|
56
|
+
* than it reports `replay-gap`, both existing port outcomes). Any later append to the run (a
|
|
57
|
+
* `resume()`) cancels the pending compaction. `null` keeps every entry forever (the old behaviour).
|
|
58
|
+
* Defaults to {@link DEFAULT_TERMINAL_EVENT_RETENTION_MS}.
|
|
59
|
+
*/
|
|
60
|
+
readonly terminalRetentionMs?: number | null;
|
|
61
|
+
/**
|
|
62
|
+
* Cap on runs held. Past it, the oldest *ended* runs are dropped entirely; a live run (no `'end'`
|
|
63
|
+
* yet, or resumed since) is never evicted to make room, so with more live runs than the cap the
|
|
64
|
+
* log holds every live run and no ended one. `null` disables the cap. Defaults to
|
|
65
|
+
* {@link DEFAULT_MAX_EVENT_LOG_RUNS}.
|
|
66
|
+
*/
|
|
67
|
+
readonly maxRuns?: number | null;
|
|
68
|
+
/** Timer port for the compaction above; defaults to unref'd native timers, so it never keeps a process alive. */
|
|
69
|
+
readonly scheduler?: SchedulerPort;
|
|
33
70
|
}
|
|
34
71
|
/**
|
|
35
72
|
* Reference `EventLog` implementation: a per-run FIFO array, optionally capped at
|
|
@@ -39,11 +76,17 @@ export interface InMemoryEventLogOptions {
|
|
|
39
76
|
* durable copy — matching this task's scope (a real persistent adapter is `@jini-ai/daemon/store/event-log/sqlite`'s
|
|
40
77
|
* job).
|
|
41
78
|
*
|
|
79
|
+
* Memory is bounded by default: an ended run is compacted to its `'end'` entry after
|
|
80
|
+
* `terminalRetentionMs`, and at most `maxRuns` runs are held (ended ones evicted first).
|
|
81
|
+
*
|
|
42
82
|
* @param options.maxEntriesPerRun - Retention cap per run, see {@link InMemoryEventLogOptions}.
|
|
83
|
+
* @param options.terminalRetentionMs - Post-`'end'` retention, see {@link InMemoryEventLogOptions}.
|
|
84
|
+
* @param options.maxRuns - Run cap, see {@link InMemoryEventLogOptions}.
|
|
43
85
|
* @returns An `EventLog` port implementation.
|
|
44
86
|
* @complexity `append`/`drop` are O(1) amortized (O(k) only on the eviction
|
|
45
|
-
* splice, k = entries over cap, which is normally 1
|
|
46
|
-
* the
|
|
87
|
+
* splice, k = entries over cap, which is normally 1; the run-cap pass is O(1)
|
|
88
|
+
* per append once at the cap). `replay` is O(n) in the number of retained
|
|
89
|
+
* entries for the run.
|
|
47
90
|
*/
|
|
48
91
|
export declare function createInMemoryEventLog(_requiredArgs: Record<string, never>, options?: InMemoryEventLogOptions): EventLog;
|
|
49
92
|
//# sourceMappingURL=event-log.d.ts.map
|
package/dist/event-log.js
CHANGED
|
@@ -1,3 +1,42 @@
|
|
|
1
|
+
import { createTimeoutScheduler } from './scheduler.js';
|
|
2
|
+
/**
|
|
3
|
+
* The protocol event that closes a run (`RunLifecycle.finish()` appends it). Seeing it is how this
|
|
4
|
+
* in-memory log knows a run's payloads have become history rather than live output.
|
|
5
|
+
*/
|
|
6
|
+
const TERMINAL_EVENT = 'end';
|
|
7
|
+
/**
|
|
8
|
+
* Default time a terminal run's full payloads stay in memory after its `'end'` entry before they are
|
|
9
|
+
* compacted down to that `'end'` entry alone. A finished run's in-memory log only has to cover a
|
|
10
|
+
* client reconnecting and replaying, which takes minutes; a host that needs the full history long
|
|
11
|
+
* term keeps a durable copy (a product transcript, or `@jini-ai/daemon/store/event-log/sqlite`).
|
|
12
|
+
* Without this, every event of every run since boot stayed on the heap — measured at 118 MB of a
|
|
13
|
+
* 430 MB daemon heap, mostly base64 screenshot tool results (memory investigation 2026-10-21).
|
|
14
|
+
* Each delegated tool result is held twice while retained: the bridge's own `tool_result` and the
|
|
15
|
+
* CLI's MCP-wrapped `tool_result` for the same call. They are not deduplicated here because their
|
|
16
|
+
* ids are uncorrelated (the MCP server mints a fresh `toolUseId` per call) and their content differs
|
|
17
|
+
* in shape, and hosts persist transcripts from this stream; this retention window bounds both.
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_TERMINAL_EVENT_RETENTION_MS = 15 * 60 * 1000;
|
|
20
|
+
/**
|
|
21
|
+
* Default cap on runs held at all. Matches `RunLifecycle`'s own `DEFAULT_MAX_TERMINAL_RUNS`, so a
|
|
22
|
+
* terminal run record the lifecycle still retains can still replay its `'end'` entry. Only ended
|
|
23
|
+
* runs are ever evicted to honour it — see {@link InMemoryEventLogOptions.maxRuns}.
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_MAX_EVENT_LOG_RUNS = 1000;
|
|
26
|
+
/**
|
|
27
|
+
* Shrinks an ended run to its newest entry (its `'end'`), keeping `nextId` so cursors stay valid.
|
|
28
|
+
* Dedupe keys pointing at a compacted entry are forgotten with it (no production caller passes a
|
|
29
|
+
* `dedupeKey` today); keeping them would keep the payloads alive.
|
|
30
|
+
*/
|
|
31
|
+
function compactRunLog(runLog) {
|
|
32
|
+
runLog.cancelCompaction = undefined;
|
|
33
|
+
runLog.entries.splice(0, runLog.entries.length - 1);
|
|
34
|
+
const retained = runLog.entries[0];
|
|
35
|
+
for (const [key, entry] of runLog.dedupeIndex) {
|
|
36
|
+
if (entry !== retained)
|
|
37
|
+
runLog.dedupeIndex.delete(key);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
1
40
|
/**
|
|
2
41
|
* Reference `EventLog` implementation: a per-run FIFO array, optionally capped at
|
|
3
42
|
* `maxEntriesPerRun` (opt-in — see {@link InMemoryEventLogOptions}), functionally a ring
|
|
@@ -6,24 +45,62 @@
|
|
|
6
45
|
* durable copy — matching this task's scope (a real persistent adapter is `@jini-ai/daemon/store/event-log/sqlite`'s
|
|
7
46
|
* job).
|
|
8
47
|
*
|
|
48
|
+
* Memory is bounded by default: an ended run is compacted to its `'end'` entry after
|
|
49
|
+
* `terminalRetentionMs`, and at most `maxRuns` runs are held (ended ones evicted first).
|
|
50
|
+
*
|
|
9
51
|
* @param options.maxEntriesPerRun - Retention cap per run, see {@link InMemoryEventLogOptions}.
|
|
52
|
+
* @param options.terminalRetentionMs - Post-`'end'` retention, see {@link InMemoryEventLogOptions}.
|
|
53
|
+
* @param options.maxRuns - Run cap, see {@link InMemoryEventLogOptions}.
|
|
10
54
|
* @returns An `EventLog` port implementation.
|
|
11
55
|
* @complexity `append`/`drop` are O(1) amortized (O(k) only on the eviction
|
|
12
|
-
* splice, k = entries over cap, which is normally 1
|
|
13
|
-
* the
|
|
56
|
+
* splice, k = entries over cap, which is normally 1; the run-cap pass is O(1)
|
|
57
|
+
* per append once at the cap). `replay` is O(n) in the number of retained
|
|
58
|
+
* entries for the run.
|
|
14
59
|
*/
|
|
15
60
|
export function createInMemoryEventLog(_requiredArgs, options = {}) {
|
|
16
61
|
const maxEntriesPerRun = options.maxEntriesPerRun;
|
|
17
62
|
const now = options.now ?? Date.now;
|
|
63
|
+
const terminalRetentionMs = options.terminalRetentionMs === undefined ? DEFAULT_TERMINAL_EVENT_RETENTION_MS : options.terminalRetentionMs;
|
|
64
|
+
const maxRuns = options.maxRuns === undefined ? DEFAULT_MAX_EVENT_LOG_RUNS : options.maxRuns;
|
|
65
|
+
const scheduler = options.scheduler ?? createTimeoutScheduler({});
|
|
18
66
|
const runs = new Map();
|
|
67
|
+
// Ended runs, oldest `'end'` first (Set insertion order): the only runs `enforceRunCap` may evict.
|
|
68
|
+
const endedRuns = new Set();
|
|
19
69
|
function getOrCreateRunLog(runId) {
|
|
20
70
|
let runLog = runs.get(runId);
|
|
21
71
|
if (!runLog) {
|
|
22
|
-
runLog = { entries: [], nextId: 1, dedupeIndex: new Map() };
|
|
72
|
+
runLog = { entries: [], nextId: 1, dedupeIndex: new Map(), cancelCompaction: undefined };
|
|
23
73
|
runs.set(runId, runLog);
|
|
24
74
|
}
|
|
25
75
|
return runLog;
|
|
26
76
|
}
|
|
77
|
+
function removeRun(runId) {
|
|
78
|
+
runs.get(runId)?.cancelCompaction?.();
|
|
79
|
+
runs.delete(runId);
|
|
80
|
+
endedRuns.delete(runId);
|
|
81
|
+
}
|
|
82
|
+
/** Tracks whether `runId` is live or ended after appending `event`, arming the post-`'end'` compaction. */
|
|
83
|
+
function noteRunPhase(runId, runLog, event) {
|
|
84
|
+
runLog.cancelCompaction?.();
|
|
85
|
+
runLog.cancelCompaction = undefined;
|
|
86
|
+
endedRuns.delete(runId);
|
|
87
|
+
if (event !== TERMINAL_EVENT)
|
|
88
|
+
return;
|
|
89
|
+
endedRuns.add(runId);
|
|
90
|
+
if (terminalRetentionMs === null)
|
|
91
|
+
return;
|
|
92
|
+
runLog.cancelCompaction = scheduler.schedule({ delayMs: terminalRetentionMs, callback: () => compactRunLog(runLog) });
|
|
93
|
+
}
|
|
94
|
+
/** Drops the oldest ended runs until at most `maxRuns` remain, or no ended run is left to drop. */
|
|
95
|
+
function enforceRunCap() {
|
|
96
|
+
if (maxRuns === null)
|
|
97
|
+
return;
|
|
98
|
+
for (const runId of endedRuns) {
|
|
99
|
+
if (runs.size <= maxRuns)
|
|
100
|
+
return;
|
|
101
|
+
removeRun(runId);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
27
104
|
return {
|
|
28
105
|
async append(input, options = {}) {
|
|
29
106
|
const runLog = getOrCreateRunLog(input.runId);
|
|
@@ -47,6 +124,8 @@ export function createInMemoryEventLog(_requiredArgs, options = {}) {
|
|
|
47
124
|
if (maxEntriesPerRun !== undefined && runLog.entries.length > maxEntriesPerRun) {
|
|
48
125
|
runLog.entries.splice(0, runLog.entries.length - maxEntriesPerRun);
|
|
49
126
|
}
|
|
127
|
+
noteRunPhase(input.runId, runLog, input.event);
|
|
128
|
+
enforceRunCap();
|
|
50
129
|
return entry;
|
|
51
130
|
},
|
|
52
131
|
async replay({ runId, afterCursor }) {
|
|
@@ -85,7 +164,7 @@ export function createInMemoryEventLog(_requiredArgs, options = {}) {
|
|
|
85
164
|
return Array.from(runs.keys()).sort();
|
|
86
165
|
},
|
|
87
166
|
async drop({ runId }) {
|
|
88
|
-
|
|
167
|
+
removeRun(runId);
|
|
89
168
|
},
|
|
90
169
|
};
|
|
91
170
|
}
|
|
@@ -236,6 +236,16 @@ export interface CreateDiskAttachmentStoreOptions {
|
|
|
236
236
|
*/
|
|
237
237
|
readonly retainAcrossRestarts?: boolean;
|
|
238
238
|
}
|
|
239
|
+
/** The `ftyp` box's declared end offset (big-endian size at bytes 0-3), clamped to the bytes
|
|
240
|
+
* actually present so a truncated or size-inflating file can never push the brand scan past the
|
|
241
|
+
* captured signature window.
|
|
242
|
+
*
|
|
243
|
+
* Exported so its short-body bounds guard can be exercised directly: {@link hasAvifSignature}, its
|
|
244
|
+
* only caller, already requires 8 bytes before calling it, so no call through that path can reach
|
|
245
|
+
* the guard — but the guard is what keeps this function safe on its own and stays. */
|
|
246
|
+
export declare function ftypBoxEnd({ body }: {
|
|
247
|
+
readonly body: Uint8Array;
|
|
248
|
+
}, _optional?: Record<string, never>): number;
|
|
239
249
|
/**
|
|
240
250
|
* `true` when `body` opens with an ISO-BMFF `ftyp` box whose major brand or compatible-brand list
|
|
241
251
|
* names AVIF. This is a line-for-line mirror of the CMS package's `content-type-sniffer.ts`
|
|
@@ -263,6 +273,9 @@ export interface AttachmentRecord {
|
|
|
263
273
|
batchDirectory: string;
|
|
264
274
|
dev: number;
|
|
265
275
|
ino: number;
|
|
276
|
+
/** Absent only for legacy sidecars; filled after their first successful integrity check. */
|
|
277
|
+
mtimeMs?: number;
|
|
278
|
+
ctimeMs?: number;
|
|
266
279
|
createdAt: number;
|
|
267
280
|
claimedRunId?: string;
|
|
268
281
|
/**
|
|
@@ -292,6 +305,8 @@ export interface RecordedAttachmentIdentity {
|
|
|
292
305
|
readonly dev: number;
|
|
293
306
|
readonly ino: number;
|
|
294
307
|
readonly size: number;
|
|
308
|
+
readonly mtimeMs?: number;
|
|
309
|
+
readonly ctimeMs?: number;
|
|
295
310
|
}
|
|
296
311
|
/**
|
|
297
312
|
* What the filesystem reports about a file right now — the subset of `fs.Stats` (plus `realpath`)
|
|
@@ -307,6 +322,8 @@ export interface ObservedAttachmentIdentity {
|
|
|
307
322
|
readonly dev: number;
|
|
308
323
|
readonly ino: number;
|
|
309
324
|
readonly size: number;
|
|
325
|
+
readonly mtimeMs: number;
|
|
326
|
+
readonly ctimeMs: number;
|
|
310
327
|
/** `realpath` of the file — differs from its own path when any path component became a symlink. */
|
|
311
328
|
readonly canonicalPath: string;
|
|
312
329
|
}
|
|
@@ -316,7 +333,9 @@ export interface ObservedAttachmentIdentity {
|
|
|
316
333
|
* Every condition is a distinct real attack: a regular file swapped for a symlink or directory, a
|
|
317
334
|
* parent directory swapped for a symlink pointing elsewhere, the file replaced by a different file
|
|
318
335
|
* at the same path (new inode), the same inode truncated or appended to (new size), or the path
|
|
319
|
-
* now resolving onto a different device.
|
|
336
|
+
* now resolving onto a different device. Modification/change timestamps also detect same-size
|
|
337
|
+
* rewrites and replacement files whose inode was recycled. Legacy records lack timestamps and
|
|
338
|
+
* use their original identity checks until the first verified read establishes that baseline.
|
|
320
339
|
*
|
|
321
340
|
* Pure and exported on purpose. Staging a *device* change for a file that keeps its path is not
|
|
322
341
|
* something a test can do on a real filesystem, and a check that can only be exercised in
|
|
@@ -357,6 +376,7 @@ export declare function reserveAttachmentRecords({ attachments, records, runId,
|
|
|
357
376
|
* Throws without releasing `claimed`'s reservations; releasing is the caller's job (`claim`'s own
|
|
358
377
|
* `catch`), so a rejected claim still leaves nothing half-claimed and the caller can retry with a
|
|
359
378
|
* corrected set.
|
|
379
|
+
* Missing legacy timestamps are filled from the verified stat; adoption persists that baseline.
|
|
360
380
|
*
|
|
361
381
|
* Exported so the integrity and mixed-batch checks can be exercised directly against fabricated
|
|
362
382
|
* records over real files on disk, without a full `createDiskAttachmentStore` around them.
|
|
@@ -445,12 +465,15 @@ export interface PersistedAttachmentRecord {
|
|
|
445
465
|
readonly batchId: string;
|
|
446
466
|
readonly dev: number;
|
|
447
467
|
readonly ino: number;
|
|
468
|
+
readonly mtimeMs?: number;
|
|
469
|
+
readonly ctimeMs?: number;
|
|
448
470
|
readonly createdAt: number;
|
|
449
471
|
readonly ownerId?: string;
|
|
450
472
|
}
|
|
451
473
|
/**
|
|
452
474
|
* Turns one sidecar's parsed JSON back into an `AttachmentRecord`, or `undefined` for anything that
|
|
453
475
|
* is not a well-formed record naming a file inside its own batch directory.
|
|
476
|
+
* Legacy sidecars may lack timestamps; adoption verifies their recorded identity before backfill.
|
|
454
477
|
*
|
|
455
478
|
* `batchDirectory` is re-derived here and `filePath`'s containment is re-checked against it with
|
|
456
479
|
* the same parent-equality test `register` uses — the two checks that make an adopted record no
|
|
@@ -477,6 +500,8 @@ export declare function attachmentSidecarFileName({ id }: {
|
|
|
477
500
|
* file registration accepted. Anything failing any of those is deleted — sidecar first, then the
|
|
478
501
|
* file it named, and the file ONLY when containment passed, so an unparseable sidecar can never
|
|
479
502
|
* make this an arbitrary-file-delete primitive for whatever the daemon can unlink.
|
|
503
|
+
* Legacy records use their original identity checks before timestamps are backfilled atomically.
|
|
504
|
+
* A backfill write failure propagates before cleanup, preserving the attachment for a later retry.
|
|
480
505
|
*
|
|
481
506
|
* **The retention consequence, stated rather than assumed harmless.** Because `claimedRunId` is not
|
|
482
507
|
* persisted (see {@link PersistedAttachmentRecord}), a file claimed by a run that died with the
|