@jini-ai/daemon 0.5.3 → 0.5.4
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 +4 -0
- package/dist/agent-executor/contracts.d.ts +12 -10
- package/dist/agent-executor/contracts.js +0 -9
- package/dist/agent-executor/event-translation.d.ts +2 -2
- package/dist/agent-executor/event-translation.js +0 -2
- package/dist/agent-executor/events.d.ts +9 -4
- package/dist/agent-executor/events.js +21 -5
- package/dist/agent-executor/handoff.d.ts +1 -1
- package/dist/agent-executor/handoff.js +0 -1
- package/dist/agent-executor/launch.d.ts +1 -1
- package/dist/agent-executor/launch.js +22 -1
- package/dist/agent-executor/mcp-bridge.d.ts +2 -2
- package/dist/agent-executor/mcp-bridge.js +0 -2
- package/dist/agent-executor/model-receipts.d.ts +6 -0
- package/dist/agent-executor/model-receipts.js +26 -0
- package/dist/agent-executor.d.ts +7 -1665
- package/dist/agent-executor.js +7 -3007
- package/dist/core/read-only-tools.d.ts +130 -0
- package/dist/core/read-only-tools.js +115 -0
- package/dist/core/tool-audit.d.ts +131 -0
- package/dist/core/tool-audit.js +113 -0
- package/dist/http/active-context.d.ts +2 -2
- package/dist/http/agents.d.ts +8 -2
- package/dist/http/agents.js +6 -2
- package/dist/http/component-catalog.d.ts +2 -2
- package/dist/http/connectors.d.ts +17 -17
- package/dist/http/daemon-status.d.ts +2 -2
- package/dist/http/db-ops.d.ts +3 -3
- package/dist/http/delegated-tools.d.ts +1 -1
- package/dist/http/delegated-tools.js +1 -1
- package/dist/http/frontend-sessions.d.ts +1 -1
- package/dist/http/health.d.ts +6 -6
- package/dist/http/host-tools.d.ts +2 -2
- package/dist/http/media.d.ts +4 -4
- package/dist/http/memory.d.ts +15 -15
- package/dist/http/remote-run-events.d.ts +2 -2
- package/dist/http/research.d.ts +1 -1
- package/dist/http/routines.d.ts +7 -7
- package/dist/http/run-credentials.d.ts +2 -2
- package/dist/http/run-credentials.js +2 -2
- package/dist/http/runs.d.ts +4 -4
- package/dist/http/terminals.d.ts +6 -6
- package/dist/http/tool-catalog.d.ts +2 -2
- package/dist/http/xai.d.ts +6 -6
- package/dist/index.d.ts +75 -571
- package/dist/index.js +75 -571
- package/dist/read-only-tools.d.ts +1 -129
- package/dist/read-only-tools.js +2 -114
- package/dist/run-credentials.d.ts +47 -23
- package/dist/run-credentials.js +24 -8
- package/dist/scheduler.d.ts +5 -0
- package/dist/scheduler.js +13 -1
- package/dist/session-coordination.d.ts +58 -24
- package/dist/session-coordination.js +40 -12
- package/dist/store/event-log/sqlite.js +3 -3
- package/dist/surface-exchanges.d.ts +207 -61
- package/dist/surface-exchanges.js +167 -20
- package/dist/tool-audit.d.ts +1 -113
- package/dist/tool-audit.js +2 -94
- package/dist/tool-recovery/diagnostics.d.ts +132 -0
- package/dist/tool-recovery/diagnostics.js +110 -0
- package/dist/tool-recovery/federated-refusal.d.ts +92 -0
- package/dist/tool-recovery/federated-refusal.js +152 -0
- package/dist/tool-recovery/index.d.ts +6 -0
- package/dist/tool-recovery/index.js +6 -0
- package/dist/tool-recovery/recovery.d.ts +49 -0
- package/dist/tool-recovery/recovery.js +419 -0
- package/dist/tool-recovery/redaction.d.ts +166 -0
- package/dist/tool-recovery/redaction.js +192 -0
- package/dist/tool-recovery/stack.d.ts +69 -0
- package/dist/tool-recovery/stack.js +62 -0
- package/package.json +35 -12
|
@@ -4,36 +4,51 @@ export interface ConversationRun {
|
|
|
4
4
|
readonly conversationId: string;
|
|
5
5
|
readonly runId: string;
|
|
6
6
|
}
|
|
7
|
+
/** In-process occupancy only: durable answer ownership belongs to the unfinished message.
|
|
8
|
+
* An empty tracker after restart does not prove a CLI child died; recovery must verify its saved
|
|
9
|
+
* identity before native resume, otherwise reconstruct under a fresh session. */
|
|
7
10
|
export interface LiveRunTracker {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
/** Register synchronously before awaiting, so another start observes this run as live. */
|
|
12
|
+
register(args: ConversationRun, optional?: Record<string, never>): void;
|
|
13
|
+
/** Idempotent removal; safe for a run that was never registered. */
|
|
14
|
+
unregister(args: ConversationRun, optional?: Record<string, never>): void;
|
|
15
|
+
/** Excludes runId itself; concurrent processes must not resume the same CLI transcript. */
|
|
16
|
+
hasConcurrentLiveRun(args: ConversationRun, optional?: Record<string, never>): boolean;
|
|
17
|
+
concurrentLiveRunIds(args: ConversationRun, optional?: Record<string, never>): string[];
|
|
18
|
+
/** Undefined after unregister; tool calls use this to resolve their conversation. */
|
|
12
19
|
conversationIdForRun(args: {
|
|
13
20
|
runId: string;
|
|
14
|
-
}): string | undefined;
|
|
21
|
+
}, optional?: Record<string, never>): string | undefined;
|
|
15
22
|
}
|
|
16
|
-
|
|
23
|
+
/** Fresh private maps keep daemon instances independent. */
|
|
24
|
+
export declare function createLiveRunTracker(_args: Record<string, never>, _optional?: Record<string, never>): LiveRunTracker;
|
|
17
25
|
export interface ConversationStartLock {
|
|
26
|
+
/** Serialize each conversation separately; undefined runs immediately. Rejection reaches only
|
|
27
|
+
* its caller and never poisons queued sections. Entries disappear once their queues drain. */
|
|
18
28
|
run<T>(args: {
|
|
19
29
|
conversationId: string | undefined;
|
|
20
30
|
critical: () => Promise<T>;
|
|
21
|
-
}): Promise<T>;
|
|
22
|
-
trackedConversationCount(): number;
|
|
31
|
+
}, optional?: Record<string, never>): Promise<T>;
|
|
32
|
+
trackedConversationCount(required: Record<string, never>, optional?: Record<string, never>): number;
|
|
23
33
|
}
|
|
24
|
-
/** Serialize
|
|
25
|
-
|
|
34
|
+
/** Serialize the session-binding read/decide/write so simultaneous first turns cannot mint competing
|
|
35
|
+
* sessions and orphan a binding. Lock only initialization: holding it across execution would queue
|
|
36
|
+
* turns behind minutes of work instead of allowing an immediate concurrency refusal. In-process only. */
|
|
37
|
+
export declare function createConversationStartLock(_args: Record<string, never>, _optional?: Record<string, never>): ConversationStartLock;
|
|
26
38
|
/** Existing lifecycle port; no persistence is imported or implemented here. */
|
|
27
39
|
export interface StoppingRunLifecycle {
|
|
28
40
|
onCancelRequested(args: {
|
|
29
41
|
runId: string;
|
|
30
42
|
listener: () => void;
|
|
31
|
-
}): () => void;
|
|
43
|
+
}, optional?: Record<string, never>): () => void;
|
|
32
44
|
waitForTerminal(args: {
|
|
33
45
|
runId: string;
|
|
34
|
-
}): Promise<unknown>;
|
|
46
|
+
}, optional?: Record<string, never>): Promise<unknown>;
|
|
35
47
|
}
|
|
36
48
|
export declare const STOPPING_RUN_WAIT_MS = 20000;
|
|
49
|
+
/** Wait only for cancelled peers: cancellation records intent before the CLI exits. Active peers
|
|
50
|
+
* can run for minutes and stay subject to the concurrency rule. Remove ended peers here because
|
|
51
|
+
* ordering against their own terminal cleanup is not guaranteed. The wait is bounded by timeoutMs. */
|
|
37
52
|
export declare function waitForStoppingRuns({ tracker, lifecycle, scheduler, conversationId, runId }: {
|
|
38
53
|
tracker: LiveRunTracker;
|
|
39
54
|
lifecycle: StoppingRunLifecycle;
|
|
@@ -52,20 +67,22 @@ export interface FailingRunLifecycle {
|
|
|
52
67
|
message: string;
|
|
53
68
|
};
|
|
54
69
|
};
|
|
55
|
-
}): Promise<unknown>;
|
|
70
|
+
}, optional?: Record<string, never>): Promise<unknown>;
|
|
56
71
|
finish(input: {
|
|
57
72
|
runId: string;
|
|
58
73
|
status: 'failed';
|
|
59
74
|
code: null;
|
|
60
75
|
signal: null;
|
|
61
76
|
resumable: false;
|
|
62
|
-
}): Promise<unknown>;
|
|
77
|
+
}, optional?: Record<string, never>): Promise<unknown>;
|
|
63
78
|
}
|
|
79
|
+
/** Emit the refusal reason on the run stream so the chat can show and save it. Event delivery
|
|
80
|
+
* is best-effort; finishing remains mandatory even when the stream cannot accept the event. */
|
|
64
81
|
export declare function failRunBeforeStart({ lifecycle, runId, message }: {
|
|
65
82
|
lifecycle: FailingRunLifecycle;
|
|
66
83
|
runId: string;
|
|
67
84
|
message: string;
|
|
68
|
-
}): Promise<void>;
|
|
85
|
+
}, _optional?: Record<string, never>): Promise<void>;
|
|
69
86
|
export interface AgentCapabilities {
|
|
70
87
|
readonly resumesSessionViaCli?: boolean;
|
|
71
88
|
readonly resumesSessionViaAcpLoad?: boolean;
|
|
@@ -74,42 +91,59 @@ export interface AgentCapabilities {
|
|
|
74
91
|
export interface AgentCapabilityResolver {
|
|
75
92
|
lookup(args: {
|
|
76
93
|
agentId: string;
|
|
77
|
-
}): AgentCapabilities | undefined;
|
|
94
|
+
}, optional?: Record<string, never>): AgentCapabilities | undefined;
|
|
78
95
|
}
|
|
96
|
+
/** Use declared CLI-resume capabilities, never a hand-kept agent list. Capture-style CLIs mint
|
|
97
|
+
* their own ids; handing one a host id would persist a session that does not exist. ACP-only
|
|
98
|
+
* resume uses session/load rather than CLI flags. Unknown agents fail closed. */
|
|
79
99
|
export declare function agentAcceptsHostMintedSessionId({ agentId, agents }: {
|
|
80
100
|
agentId: string;
|
|
81
101
|
agents: AgentCapabilityResolver;
|
|
82
|
-
}): boolean;
|
|
102
|
+
}, _optional?: Record<string, never>): boolean;
|
|
103
|
+
/** CLI/ACP-resuming agents carry prior turns and may receive only the newest user message.
|
|
104
|
+
* Resolve capabilities synchronously through the port; no PATH probing is needed. */
|
|
83
105
|
export declare function agentCarriesOwnMemory({ agentId, agents }: {
|
|
84
106
|
agentId: string;
|
|
85
107
|
agents: AgentCapabilityResolver;
|
|
86
|
-
}): boolean;
|
|
108
|
+
}, _optional?: Record<string, never>): boolean;
|
|
109
|
+
/** Mint only a cold, conversation-scoped start for a host-id-capable agent. Persist and pass the
|
|
110
|
+
* same id before spawning: relying only on a terminal report can orphan a crashed first turn.
|
|
111
|
+
* A resume already has a binding; replacing it would fork the conversation. */
|
|
87
112
|
export declare function resolveHostMintedSessionId(input: {
|
|
88
113
|
conversationId: string | undefined;
|
|
89
114
|
effectiveResumeSessionId: string | null;
|
|
90
115
|
acceptsHostMintedSessionId: boolean;
|
|
91
116
|
mint: () => string;
|
|
92
|
-
}): string | null;
|
|
117
|
+
}, _optional?: Record<string, never>): string | null;
|
|
118
|
+
/** Return { newSessionId } when present, otherwise omit the key entirely for the run-input spread. */
|
|
93
119
|
export declare function resolveNewSessionField({ hostMintedSessionId }: {
|
|
94
120
|
hostMintedSessionId: string | null;
|
|
95
|
-
}): {
|
|
121
|
+
}, _optional?: Record<string, never>): {
|
|
96
122
|
newSessionId?: string;
|
|
97
123
|
};
|
|
124
|
+
/** Return { resumeSessionId } when present, otherwise omit the key entirely for the run-input spread. */
|
|
98
125
|
export declare function resolveResumeSessionField({ storedSessionId }: {
|
|
99
126
|
storedSessionId: string | null;
|
|
100
|
-
}): {
|
|
127
|
+
}, _optional?: Record<string, never>): {
|
|
101
128
|
resumeSessionId?: string;
|
|
102
129
|
};
|
|
130
|
+
/** Only a terminal end with a nonempty sessionRef establishes a session id to persist. */
|
|
103
131
|
export declare function extractSessionRefFromEndEvent({ event }: {
|
|
104
132
|
event: RunProtocolEvent;
|
|
105
|
-
}): string | undefined;
|
|
133
|
+
}, _optional?: Record<string, never>): string | undefined;
|
|
134
|
+
/** Clear only an attempted resume whose terminal event reconfirms no session. The event cannot
|
|
135
|
+
* distinguish a dead session from an unrelated early failure: one cold turn costs less than
|
|
136
|
+
* retrying an unrecoverable id forever. A cold run has no stored binding to clear. */
|
|
106
137
|
export declare function shouldClearSessionOnFailedResume({ event, attemptedResumeSessionId }: {
|
|
107
138
|
event: RunProtocolEvent;
|
|
108
139
|
attemptedResumeSessionId: string | null;
|
|
109
|
-
}): boolean;
|
|
140
|
+
}, _optional?: Record<string, never>): boolean;
|
|
141
|
+
/** Refuse a forced cold start when a stored session, concurrent peer and memory-carrying agent
|
|
142
|
+
* coincide: the client may have sent only the latest prompt, so cold execution loses prior turns.
|
|
143
|
+
* A first turn or an agent receiving the full transcript has no such context to lose. */
|
|
110
144
|
export declare function wouldForcedColdStartLoseConversationContext(input: {
|
|
111
145
|
storedSessionId: string | null;
|
|
112
146
|
hasConcurrentLiveRun: boolean;
|
|
113
147
|
carriesOwnMemory: boolean;
|
|
114
|
-
}): boolean;
|
|
148
|
+
}, _optional?: Record<string, never>): boolean;
|
|
115
149
|
//# sourceMappingURL=session-coordination.d.ts.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
/** Fresh private maps keep daemon instances independent. */
|
|
2
|
+
export function createLiveRunTracker(_args, _optional = {}) {
|
|
2
3
|
const runs = new Map();
|
|
3
4
|
const conversations = new Map();
|
|
4
5
|
function unregister({ conversationId, runId }) {
|
|
@@ -30,16 +31,20 @@ export function createLiveRunTracker(_args) {
|
|
|
30
31
|
conversationIdForRun: ({ runId }) => conversations.get(runId),
|
|
31
32
|
};
|
|
32
33
|
}
|
|
33
|
-
/** Serialize
|
|
34
|
-
|
|
34
|
+
/** Serialize the session-binding read/decide/write so simultaneous first turns cannot mint competing
|
|
35
|
+
* sessions and orphan a binding. Lock only initialization: holding it across execution would queue
|
|
36
|
+
* turns behind minutes of work instead of allowing an immediate concurrency refusal. In-process only. */
|
|
37
|
+
export function createConversationStartLock(_args, _optional = {}) {
|
|
35
38
|
const tails = new Map();
|
|
36
39
|
return {
|
|
37
40
|
run({ conversationId, critical }) {
|
|
38
41
|
if (conversationId === undefined)
|
|
39
42
|
return critical();
|
|
40
43
|
const result = (tails.get(conversationId) ?? Promise.resolve()).then(critical);
|
|
44
|
+
// A newer section may own this key already; deleting its tail would allow a third to race it.
|
|
41
45
|
const release = () => { if (tails.get(conversationId) === tail)
|
|
42
46
|
tails.delete(conversationId); };
|
|
47
|
+
// Both settlements release immediately and make the tail non-rejecting for the next section.
|
|
43
48
|
const tail = result.then(release, release);
|
|
44
49
|
tails.set(conversationId, tail);
|
|
45
50
|
return result;
|
|
@@ -48,9 +53,13 @@ export function createConversationStartLock(_args) {
|
|
|
48
53
|
};
|
|
49
54
|
}
|
|
50
55
|
export const STOPPING_RUN_WAIT_MS = 20_000;
|
|
56
|
+
/** Wait only for cancelled peers: cancellation records intent before the CLI exits. Active peers
|
|
57
|
+
* can run for minutes and stay subject to the concurrency rule. Remove ended peers here because
|
|
58
|
+
* ordering against their own terminal cleanup is not guaranteed. The wait is bounded by timeoutMs. */
|
|
51
59
|
export async function waitForStoppingRuns({ tracker, lifecycle, scheduler, conversationId, runId }, { timeoutMs = STOPPING_RUN_WAIT_MS } = {}) {
|
|
52
60
|
const stopping = tracker.concurrentLiveRunIds({ conversationId, runId }).filter(id => {
|
|
53
61
|
let cancelled = false;
|
|
62
|
+
// Subscription replays a past cancellation synchronously; immediate unsubscribe avoids leaks.
|
|
54
63
|
try {
|
|
55
64
|
lifecycle.onCancelRequested({ runId: id, listener: () => { cancelled = true; } })();
|
|
56
65
|
}
|
|
@@ -72,40 +81,59 @@ export async function waitForStoppingRuns({ tracker, lifecycle, scheduler, conve
|
|
|
72
81
|
cancelTimer?.();
|
|
73
82
|
}
|
|
74
83
|
}
|
|
75
|
-
|
|
84
|
+
/** Emit the refusal reason on the run stream so the chat can show and save it. Event delivery
|
|
85
|
+
* is best-effort; finishing remains mandatory even when the stream cannot accept the event. */
|
|
86
|
+
export async function failRunBeforeStart({ lifecycle, runId, message }, _optional = {}) {
|
|
76
87
|
try {
|
|
77
88
|
await lifecycle.emit({ runId: runId, input: { event: 'error', data: { message } } });
|
|
78
89
|
}
|
|
79
90
|
catch { /* Finishing remains mandatory. */ }
|
|
80
91
|
await lifecycle.finish({ runId, status: 'failed', code: null, signal: null, resumable: false });
|
|
81
92
|
}
|
|
82
|
-
|
|
93
|
+
/** Use declared CLI-resume capabilities, never a hand-kept agent list. Capture-style CLIs mint
|
|
94
|
+
* their own ids; handing one a host id would persist a session that does not exist. ACP-only
|
|
95
|
+
* resume uses session/load rather than CLI flags. Unknown agents fail closed. */
|
|
96
|
+
export function agentAcceptsHostMintedSessionId({ agentId, agents }, _optional = {}) {
|
|
83
97
|
const def = agents.lookup({ agentId });
|
|
84
98
|
return Boolean(def?.resumesSessionViaCli) && !def?.capturesSessionIdFromStream;
|
|
85
99
|
}
|
|
86
|
-
|
|
100
|
+
/** CLI/ACP-resuming agents carry prior turns and may receive only the newest user message.
|
|
101
|
+
* Resolve capabilities synchronously through the port; no PATH probing is needed. */
|
|
102
|
+
export function agentCarriesOwnMemory({ agentId, agents }, _optional = {}) {
|
|
87
103
|
const def = agents.lookup({ agentId });
|
|
88
104
|
return Boolean(def?.resumesSessionViaCli) || Boolean(def?.resumesSessionViaAcpLoad);
|
|
89
105
|
}
|
|
90
|
-
|
|
106
|
+
/** Mint only a cold, conversation-scoped start for a host-id-capable agent. Persist and pass the
|
|
107
|
+
* same id before spawning: relying only on a terminal report can orphan a crashed first turn.
|
|
108
|
+
* A resume already has a binding; replacing it would fork the conversation. */
|
|
109
|
+
export function resolveHostMintedSessionId(input, _optional = {}) {
|
|
91
110
|
return input.conversationId !== undefined && input.effectiveResumeSessionId === null && input.acceptsHostMintedSessionId ? input.mint() : null;
|
|
92
111
|
}
|
|
93
|
-
|
|
112
|
+
/** Return { newSessionId } when present, otherwise omit the key entirely for the run-input spread. */
|
|
113
|
+
export function resolveNewSessionField({ hostMintedSessionId }, _optional = {}) {
|
|
94
114
|
return hostMintedSessionId !== null ? { newSessionId: hostMintedSessionId } : {};
|
|
95
115
|
}
|
|
96
|
-
|
|
116
|
+
/** Return { resumeSessionId } when present, otherwise omit the key entirely for the run-input spread. */
|
|
117
|
+
export function resolveResumeSessionField({ storedSessionId }, _optional = {}) {
|
|
97
118
|
return storedSessionId !== null ? { resumeSessionId: storedSessionId } : {};
|
|
98
119
|
}
|
|
99
|
-
|
|
120
|
+
/** Only a terminal end with a nonempty sessionRef establishes a session id to persist. */
|
|
121
|
+
export function extractSessionRefFromEndEvent({ event }, _optional = {}) {
|
|
100
122
|
if (event.kind !== 'end')
|
|
101
123
|
return undefined;
|
|
102
124
|
const id = event.payload.sessionRef;
|
|
103
125
|
return typeof id === 'string' && id.length > 0 ? id : undefined;
|
|
104
126
|
}
|
|
105
|
-
|
|
127
|
+
/** Clear only an attempted resume whose terminal event reconfirms no session. The event cannot
|
|
128
|
+
* distinguish a dead session from an unrelated early failure: one cold turn costs less than
|
|
129
|
+
* retrying an unrecoverable id forever. A cold run has no stored binding to clear. */
|
|
130
|
+
export function shouldClearSessionOnFailedResume({ event, attemptedResumeSessionId }, _optional = {}) {
|
|
106
131
|
return attemptedResumeSessionId !== null && event.kind === 'end' && extractSessionRefFromEndEvent({ event }) === undefined;
|
|
107
132
|
}
|
|
108
|
-
|
|
133
|
+
/** Refuse a forced cold start when a stored session, concurrent peer and memory-carrying agent
|
|
134
|
+
* coincide: the client may have sent only the latest prompt, so cold execution loses prior turns.
|
|
135
|
+
* A first turn or an agent receiving the full transcript has no such context to lose. */
|
|
136
|
+
export function wouldForcedColdStartLoseConversationContext(input, _optional = {}) {
|
|
109
137
|
return input.storedSessionId !== null && input.hasConcurrentLiveRun && input.carriesOwnMemory;
|
|
110
138
|
}
|
|
111
139
|
//# sourceMappingURL=session-coordination.js.map
|
|
@@ -81,10 +81,10 @@ export function openSqliteEventLog(input, options = {}) {
|
|
|
81
81
|
resolveMaxEntriesPerRun(options.maxEntriesPerRun);
|
|
82
82
|
let db;
|
|
83
83
|
try {
|
|
84
|
-
db = openSqliteConnection({ filePath: input.file, open(
|
|
85
|
-
db = input.open(
|
|
84
|
+
db = openSqliteConnection({ filePath: input.file, open({ filePath }, settings) {
|
|
85
|
+
db = input.open(filePath, settings);
|
|
86
86
|
return db;
|
|
87
|
-
}, pragmas: ['journal_mode = WAL'] });
|
|
87
|
+
} }, { pragmas: ['journal_mode = WAL'] });
|
|
88
88
|
const log = bindSqliteEventLog(db, options);
|
|
89
89
|
let closed = false;
|
|
90
90
|
return { ...log, async close(_args) { if (closed)
|
|
@@ -1,61 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file A **surface exchange**: a two-way, multi-message conversation with a human, held open inside
|
|
3
|
+
* one agent tool call.
|
|
4
|
+
*
|
|
5
|
+
* ## What this is
|
|
6
|
+
*
|
|
7
|
+
* The agent calls a tool. The tool opens an exchange, sends something for a person to look at, and
|
|
8
|
+
* waits. The person answers. The tool may send again — a follow-up question, an updated component
|
|
9
|
+
* tree, a validation error — and wait again, any number of times. When it is done it closes the
|
|
10
|
+
* exchange and returns, and the human's answers are the tool call's ordinary result. The agent is
|
|
11
|
+
* still alive the whole time, which is what makes the transcript correct by construction rather than
|
|
12
|
+
* reconstructed afterwards (ADR-055 Decision 4).
|
|
13
|
+
*
|
|
14
|
+
* A one-shot ask — show a form, get one submission — is the degenerate case, expressed by
|
|
15
|
+
* {@link askOnce}. It is a helper over this, not a separate mechanism.
|
|
16
|
+
*
|
|
17
|
+
* ## Channel-agnostic on purpose
|
|
18
|
+
*
|
|
19
|
+
* Nothing here knows what a surface *is*. Outbound messages are `@jini-ai/core`'s
|
|
20
|
+
* {@link SurfaceEmission}, which names its own channel; inbound answers are plain records. That is
|
|
21
|
+
* what lets one exchange drive MCP-UI (an HTML surface answering by tool call), A2UI (`createSurface`
|
|
22
|
+
* → action → `updateComponents` → action, which is inherently multi-turn), the run protocol's own
|
|
23
|
+
* channel-neutral `surface_request`/`surface_response` pair, or a paradigm that does not exist yet.
|
|
24
|
+
* The transports differ; "hold the agent's call open and pass messages both ways" does not.
|
|
25
|
+
*
|
|
26
|
+
* ## The inbound buffer is a correctness requirement, not a nicety
|
|
27
|
+
*
|
|
28
|
+
* A message can arrive while the handler is between {@link SurfaceExchange.receive} calls — busy
|
|
29
|
+
* composing its next send. Without somewhere to put it, that message is dropped and the exchange
|
|
30
|
+
* deadlocks on an answer the human already gave. So deliveries queue. This is the one thing that is
|
|
31
|
+
* genuinely painful to retrofit onto a one-shot store, which is why it is here before there is a
|
|
32
|
+
* multi-turn channel wired to need it.
|
|
33
|
+
*
|
|
34
|
+
* ## Opening requires an emitter, so the other deadlock is unrepresentable
|
|
35
|
+
*
|
|
36
|
+
* The second deadlock is structural: a handler waiting for an answer to a message it never managed
|
|
37
|
+
* to send. The daemon reads surfaces out of a *completed* tool result, so the return value cannot
|
|
38
|
+
* carry a message the same call is waiting on — only `ToolExecutionContext.emitSurface` can.
|
|
39
|
+
* {@link SurfaceExchangeStore.open} therefore takes that emitter as a required argument. A handler
|
|
40
|
+
* with no emitter cannot open an exchange at all, and must fail closed without an interactive channel.
|
|
41
|
+
*
|
|
42
|
+
* ## Not a secret
|
|
43
|
+
*
|
|
44
|
+
* An exchange id is a **correlation handle**. It says which in-flight call a message belongs to and
|
|
45
|
+
* confers nothing — contrast the former token store, which minted a genuine secret because the
|
|
46
|
+
* delete it guards is a second tool call the model could otherwise make itself (ADR-055 Decision 3).
|
|
47
|
+
* Ids here are stored in the clear and compared with `===`. Copying that module's hashing and
|
|
48
|
+
* constant-time comparison would imply a security property this handle does not carry.
|
|
49
|
+
*
|
|
50
|
+
* The property this file IS responsible for is narrower: **a message resolves at most one waiting
|
|
51
|
+
* receive, on an exchange opened by the same tool for the same principal.**
|
|
52
|
+
*
|
|
53
|
+
*/
|
|
1
54
|
import type { Clock, IdGenerator } from '@jini-ai/core/primitives';
|
|
2
|
-
import type { SurfaceEmission,
|
|
55
|
+
import type { SurfaceEmission, SurfaceAnswerChannel, SurfaceMessage, SurfaceExchangeConversation, SurfaceExchangeStore } from '@jini-ai/core';
|
|
56
|
+
export { SURFACE_EXCHANGE_ID_PARAM, SURFACE_DISMISSED_PARAM, SURFACE_TYPED_ANSWER_PARAM, } from '@jini-ai/core';
|
|
57
|
+
export type { SurfaceAnswerChannel, SurfaceExchangeBinding, SurfaceMessage, SurfaceDeliveryRejectionReason, DeliverResult, SurfaceExchange, SurfaceExchangeConversation, SurfaceDeliverySpec, SurfaceExchangeStore, SurfaceAskThenReport, } from '@jini-ai/core';
|
|
3
58
|
import type { SchedulerPort } from './scheduler.js';
|
|
4
59
|
export type { SchedulerPort } from './scheduler.js';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
60
|
+
/**
|
|
61
|
+
* How long an exchange may sit with nothing happening before it gives up.
|
|
62
|
+
*
|
|
63
|
+
* Per *turn*, not per exchange: it resets whenever a message is sent or received, so a long
|
|
64
|
+
* multi-turn conversation is not punished for taking several turns — only for a human who walked
|
|
65
|
+
* away. Long enough to read a dialog and decide, short enough that an abandoned tab does not hold a
|
|
66
|
+
* live agent subprocess open.
|
|
67
|
+
*/
|
|
8
68
|
export declare const DEFAULT_SURFACE_IDLE_TTL_MS: number;
|
|
69
|
+
/**
|
|
70
|
+
* Hard ceiling on one exchange's total life, regardless of activity.
|
|
71
|
+
*
|
|
72
|
+
* Exists because the idle deadline alone is unbounded in aggregate — a human answering every four
|
|
73
|
+
* minutes could hold a call open indefinitely, and the call is not ours to hold: it is an HTTP
|
|
74
|
+
* request from the spawned agent's MCP server, which gives up after 6 minutes (`@jini-ai/mcp`'s
|
|
75
|
+
* delegated-tool deadline). Sized just inside that, so a stalled exchange returns an explicit result
|
|
76
|
+
* the model can read rather than having the transport time out underneath it.
|
|
77
|
+
*
|
|
78
|
+
* **This, not the idle deadline, is what caps a multi-turn conversation today.** Raising it means
|
|
79
|
+
* raising the transport deadline first; the two must move together or the ordering inverts.
|
|
80
|
+
*/
|
|
9
81
|
export declare const DEFAULT_SURFACE_MAX_LIFETIME_MS: number;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
export type DeliverResult = {
|
|
26
|
-
ok: true;
|
|
27
|
-
} | {
|
|
28
|
-
ok: false;
|
|
29
|
-
reason: SurfaceDeliveryRejectionReason;
|
|
30
|
-
};
|
|
31
|
-
export interface SurfaceExchange {
|
|
32
|
-
readonly id: string;
|
|
33
|
-
send(args: {
|
|
34
|
-
emission: SurfaceEmission;
|
|
35
|
-
}): Promise<void>;
|
|
36
|
-
receive(_args: Record<string, never>): Promise<SurfaceMessage>;
|
|
37
|
-
close(_args: Record<string, never>): void;
|
|
38
|
-
}
|
|
39
|
-
export interface SurfaceDeliverySpec {
|
|
40
|
-
exchangeId: string;
|
|
41
|
-
params: Record<string, unknown>;
|
|
42
|
-
principalId: string;
|
|
43
|
-
toolId?: string;
|
|
44
|
-
channel?: SurfaceAnswerChannel;
|
|
45
|
-
}
|
|
46
|
-
export interface SurfaceExchangeStore {
|
|
47
|
-
open(args: {
|
|
48
|
-
binding: SurfaceExchangeBinding;
|
|
49
|
-
emit: SurfaceEmitter;
|
|
50
|
-
}): SurfaceExchange;
|
|
51
|
-
deliver(requiredArgs: Pick<SurfaceDeliverySpec, "exchangeId" | "params" | "principalId">, optionalArgs?: Pick<SurfaceDeliverySpec, "toolId" | "channel">): DeliverResult;
|
|
52
|
-
findTypedAnswerTarget(args: {
|
|
53
|
-
principalId: string;
|
|
54
|
-
toolId: string;
|
|
55
|
-
}): string | undefined;
|
|
56
|
-
size(): number;
|
|
57
|
-
}
|
|
58
|
-
/** In-process held calls. Inject a scheduler that unrefs its timers where appropriate. */
|
|
82
|
+
/**
|
|
83
|
+
* Creates an in-process store of open surface exchanges.
|
|
84
|
+
*
|
|
85
|
+
* In-process necessarily, not merely by choice: an open exchange is a suspended function call in
|
|
86
|
+
* this process's memory. It could not be shared across processes even if that were desirable.
|
|
87
|
+
* `mcp-ui-tool-calls-route.ts` is mounted on the same daemon the handlers run in, which is what makes
|
|
88
|
+
* delivery a direct call rather than another network hop.
|
|
89
|
+
*
|
|
90
|
+
* @param options.idleTtlMs - Per-turn inactivity deadline. See {@link DEFAULT_SURFACE_IDLE_TTL_MS}.
|
|
91
|
+
* @param options.maxLifetimeMs - Total lifetime ceiling. See {@link DEFAULT_SURFACE_MAX_LIFETIME_MS}.
|
|
92
|
+
* @param deps.idGenerator - Id source, injected so a test can assert a known value.
|
|
93
|
+
* @param deps.clock - Wall clock for {@link SurfaceExchange.expiresAtMs}. The injected scheduler owns
|
|
94
|
+
* the timers; the clock dates and checks their deadlines.
|
|
95
|
+
* @complexity O(1) per send/receive/deliver. Each exchange owns two `unref`'d timers.
|
|
96
|
+
*/
|
|
59
97
|
export declare function createSurfaceExchangeStore(deps: {
|
|
60
98
|
scheduler: SchedulerPort;
|
|
61
99
|
clock: Clock;
|
|
@@ -65,33 +103,141 @@ export declare function createSurfaceExchangeStore(deps: {
|
|
|
65
103
|
idleTtlMs?: number;
|
|
66
104
|
maxLifetimeMs?: number;
|
|
67
105
|
}): SurfaceExchangeStore;
|
|
106
|
+
/**
|
|
107
|
+
* The one-shot case: send once, wait once, close.
|
|
108
|
+
*
|
|
109
|
+
* Kept as a helper rather than a second mechanism, because "ask a question and get one answer" is
|
|
110
|
+
* genuinely just the shortest exchange. Anything that later needs a follow-up turn stops calling this
|
|
111
|
+
* and drives the exchange directly, with no change to the store, the route, or the transport.
|
|
112
|
+
*/
|
|
68
113
|
export declare function askOnce({ exchange, emission }: {
|
|
69
|
-
exchange:
|
|
114
|
+
exchange: SurfaceExchangeConversation;
|
|
70
115
|
emission: SurfaceEmission;
|
|
71
|
-
}): Promise<SurfaceMessage>;
|
|
116
|
+
}, _optional?: Record<string, never>): Promise<SurfaceMessage>;
|
|
117
|
+
/**
|
|
118
|
+
* Like {@link askOnce}, but for a call whose answer triggers real work whose OUTCOME the human also
|
|
119
|
+
* needs to see — a publish, a delete, a credential save. `askOnce` alone cannot express this: it
|
|
120
|
+
* closes the exchange the instant an answer arrives, so a caller that awaited it and then tried to
|
|
121
|
+
* `exchange.send()` a result would find the exchange already dead (`send()` throws "already ended").
|
|
122
|
+
*
|
|
123
|
+
* ## The defect this exists to fix
|
|
124
|
+
*
|
|
125
|
+
* A confirmation surface's own script (`confirmation.ts`) sets its status text to "Done." the moment
|
|
126
|
+
* the confirming `tools/call` RESOLVES — and for a held-open exchange delivered through
|
|
127
|
+
* `mcp-ui-tool-calls-route.ts`'s Shape 1, that call resolves the instant the click is DELIVERED to
|
|
128
|
+
* the parked agent call (`{delivered: true}`, HTTP 202), which happens before the parked handler has
|
|
129
|
+
* done anything with the answer yet, let alone finished. "Done." therefore means "your click
|
|
130
|
+
* reached the server," never "the thing you asked for actually happened" — a 404, a rejected
|
|
131
|
+
* credential, and a genuine success all render the identical "Done." to the human. `handle` below is
|
|
132
|
+
* where a caller does the real work and decides what actually happened; the outcome emission this
|
|
133
|
+
* function sends afterward is what corrects the record.
|
|
134
|
+
*
|
|
135
|
+
* ## How the correction reaches the screen
|
|
136
|
+
*
|
|
137
|
+
* The outcome emission is expected to reuse the SAME `ui://` URI the confirmation was sent under.
|
|
138
|
+
* `McpUiSurfaceCard` (`@jini-ai/chat`) already collapses a stream to the newest resource per URI —
|
|
139
|
+
* "a stream may re-send an updated document for a surface already on screen (a confirmation that
|
|
140
|
+
* became a result), so the LAST event for each URI wins" (that component's own doc) — and
|
|
141
|
+
* `McpUiHost` keys its iframe by `` `${uri}:${documentText}` ``, so a new document forces a clean
|
|
142
|
+
* remount rather than leaving the confirmation's stale Confirm/Cancel buttons on screen. Neither of
|
|
143
|
+
* those needed a change for this to work; this function only needed to make the SECOND send possible.
|
|
144
|
+
*
|
|
145
|
+
* ## Why `handle` returns the outcome rather than the caller sending it separately
|
|
146
|
+
*
|
|
147
|
+
* Keeping the send here, not in the caller, is what makes the try/catch below unconditional: a
|
|
148
|
+
* caller could forget to wrap its own `exchange.send()`, but every caller of this function gets the
|
|
149
|
+
* protection for free. `outcome` is OPTIONAL in the return — a cancelled/expired/abandoned answer has
|
|
150
|
+
* nothing to correct (the confirmation's own script already reports "Dismissed." for a local cancel,
|
|
151
|
+
* and an exchange that ended via a timeout is already closed, so `send()` would throw anyway); a
|
|
152
|
+
* caller for that branch simply omits it.
|
|
153
|
+
*
|
|
154
|
+
* ## The one invariant this function protects
|
|
155
|
+
*
|
|
156
|
+
* `result` — what reaches the model — must NEVER depend on the outcome emission actually reaching a
|
|
157
|
+
* screen. A human who closed the tab, a `send()` racing a teardown, or any other failure in the
|
|
158
|
+
* human-visible half must be invisible to the tool's own JSON contract, which is why the outcome send
|
|
159
|
+
* is wrapped in its own `try`/`catch` rather than sharing `handle`'s.
|
|
160
|
+
*
|
|
161
|
+
* @param exchange - Opened the same way `askOnce` expects one.
|
|
162
|
+
* @param confirmationEmission - Sent once, exactly like `askOnce`'s own `emission` parameter.
|
|
163
|
+
* @param handle - Runs after the answer arrives (whatever it is — received, expired, abandoned).
|
|
164
|
+
* Does the caller's real work and returns `result` (returned to THIS function's own caller,
|
|
165
|
+
* ultimately the model) plus an optional `outcome` emission to report back to the human.
|
|
166
|
+
* @complexity O(1) plus whatever `handle` itself costs — this function adds one conditional send and
|
|
167
|
+
* the same `close()` `askOnce` already pays.
|
|
168
|
+
*/
|
|
72
169
|
export declare function askThenReport<T>({ exchange, confirmationEmission, handle }: {
|
|
73
|
-
exchange:
|
|
170
|
+
exchange: SurfaceExchangeConversation;
|
|
74
171
|
confirmationEmission: SurfaceEmission;
|
|
75
172
|
handle: (answer: SurfaceMessage) => Promise<{
|
|
76
173
|
result: T;
|
|
77
174
|
outcome?: SurfaceEmission;
|
|
78
175
|
}>;
|
|
79
|
-
}): Promise<T>;
|
|
176
|
+
}, _optional?: Record<string, never>): Promise<T>;
|
|
177
|
+
/**
|
|
178
|
+
* Whether the human confirmed, declined, or never answered at all — the fail-closed classification
|
|
179
|
+
* every confirm/cancel-shaped tool call needs (a delete, a commit, a publish, ...). Extracted after
|
|
180
|
+
* this exact logic had been copy-pasted at least 4 times (`features/post/tool-registrations.ts`'s
|
|
181
|
+
* `resolveDeleteDecision`, then `features/custom-credentials`, `features/source-control`, and
|
|
182
|
+
* `features/deployments/publish-agent-tools.ts`, each self-described as mirroring the first), with
|
|
183
|
+
* the SAME "fail closed, never read a missing/malformed decision as consent" fix required at every
|
|
184
|
+
* site (see `classifyConfirmationAnswer`'s own doc).
|
|
185
|
+
*
|
|
186
|
+
* `"declined"` covers an explicit non-`"confirm"` decision — a real Cancel click, and equally a
|
|
187
|
+
* missing, non-string, or unrecognised `decision` field, all fail-closed to the same outcome.
|
|
188
|
+
* `"expired"`/`"abandoned"` mirror {@link SurfaceMessage}'s own non-`"received"` statuses one-for-one
|
|
189
|
+
* (ADR-055 Decision 6: no-answer is a result, not a thrown error).
|
|
190
|
+
*/
|
|
80
191
|
export type ConfirmationOutcome = {
|
|
81
192
|
confirmed: true;
|
|
82
193
|
} | {
|
|
83
194
|
confirmed: false;
|
|
84
195
|
reason: 'declined' | 'expired' | 'abandoned';
|
|
85
196
|
};
|
|
86
|
-
/**
|
|
197
|
+
/**
|
|
198
|
+
* Classifies an already-received {@link SurfaceMessage} as confirmed or not.
|
|
199
|
+
*
|
|
200
|
+
* Pure and synchronous on purpose: a caller that already holds an `answer` from somewhere other than
|
|
201
|
+
* a fresh {@link askOnce} call — `askThenReport`'s own `handle` callback, for instance, which receives
|
|
202
|
+
* the message directly because a second `exchange.send()` still needs to happen afterward — can
|
|
203
|
+
* classify it without a second round trip. {@link resolveConfirmationDecision} below is the `askOnce`
|
|
204
|
+
* convenience built on top of this for callers that do not need that.
|
|
205
|
+
*
|
|
206
|
+
* Fail-closed is the entire contract: only a `params.decision` field that is literally the string
|
|
207
|
+
* `"confirm"` proceeds. A missing field, a non-string value, or any other string must never be read
|
|
208
|
+
* as consent for a destructive or externally-visible action.
|
|
209
|
+
*
|
|
210
|
+
* @complexity O(1).
|
|
211
|
+
*/
|
|
87
212
|
export declare function classifyConfirmationAnswer({ answer }: {
|
|
88
213
|
answer: SurfaceMessage;
|
|
89
|
-
}): ConfirmationOutcome;
|
|
214
|
+
}, _optional?: Record<string, never>): ConfirmationOutcome;
|
|
215
|
+
/**
|
|
216
|
+
* {@link askOnce} plus {@link classifyConfirmationAnswer} — the whole "ask, then fail-closed classify"
|
|
217
|
+
* sequence every confirm/cancel-shaped delete/commit/publish tool repeats. A caller still owns its own
|
|
218
|
+
* domain-specific result shape for each branch (see e.g. `features/post/tool-registrations.ts`'s own
|
|
219
|
+
* `resolveDeleteDecision`) — this only shares the parked-ask-and-classify mechanics, not the result,
|
|
220
|
+
* so each site's `deleted`/`committed`/`published`/... wording stays exactly what it was.
|
|
221
|
+
*
|
|
222
|
+
* @complexity O(1) plus whatever {@link askOnce} costs.
|
|
223
|
+
*/
|
|
90
224
|
export declare function resolveConfirmationDecision(args: {
|
|
91
|
-
exchange:
|
|
225
|
+
exchange: SurfaceExchangeConversation;
|
|
92
226
|
emission: SurfaceEmission;
|
|
93
|
-
}): Promise<ConfirmationOutcome>;
|
|
227
|
+
}, _optional?: Record<string, never>): Promise<ConfirmationOutcome>;
|
|
228
|
+
/**
|
|
229
|
+
* Cross-domain machinery a surface-raising tool needs that is not a domain dependency at all.
|
|
230
|
+
*
|
|
231
|
+
* Kept separate from `tool-registrations.ts`'s `AssistantToolRegistryDeps` on purpose: that bag is
|
|
232
|
+
* "the same dependencies the admin HTTP routes are built from", which is what makes a tool call and
|
|
233
|
+
* the equivalent human click reach identical domain code. An exchange store is not part of that
|
|
234
|
+
* equivalence — it belongs to the assistant's transport, and only tools that talk to a human mid-call
|
|
235
|
+
* ever touch it. Declared in this module rather than alongside the registration wiring so a tool can
|
|
236
|
+
* name it without importing the file that imports the tool.
|
|
237
|
+
*/
|
|
94
238
|
export interface AssistantSurfaceDeps {
|
|
239
|
+
/** The handlers and callback route must share this same in-process store. Opening in one store
|
|
240
|
+
* and delivering to another leaves the human's answer unreachable until the deadline expires. */
|
|
95
241
|
readonly surfaceExchanges: SurfaceExchangeStore;
|
|
96
242
|
}
|
|
97
243
|
//# sourceMappingURL=surface-exchanges.d.ts.map
|