@matatbread/matbot-core 0.4.18 → 0.4.20
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/package.json +2 -2
- package/src/assemble.ts +3 -3
- package/src/config/loader.ts +0 -18
- package/src/index.ts +2 -2
- package/src/providers-base/index.ts +1 -0
- package/src/providers-base/text-document.ts +55 -0
- package/src/registry.ts +4 -3
- package/src/runner.ts +55 -4
- package/src/security/vault.ts +6 -14
- package/src/session-runner.ts +55 -17
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.4.
|
|
7
|
+
"version": "0.4.20",
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=24.0.0"
|
|
10
10
|
},
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@matatbread/matbot-plugin-api": "^0.4.
|
|
38
|
+
"@matatbread/matbot-plugin-api": "^0.4.20"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^6.0.3"
|
package/src/assemble.ts
CHANGED
|
@@ -69,8 +69,6 @@ export interface AssembleOptions {
|
|
|
69
69
|
providers: ProviderRegistryImpl;
|
|
70
70
|
gate: HostGate;
|
|
71
71
|
defaultSettings?: ReadonlyMap<string, Readonly<Record<string, unknown>>> | undefined;
|
|
72
|
-
/** Registry values present from boot, which an unregister reverts to rather than deleting. */
|
|
73
|
-
seed?: Partial<MatbotServices>;
|
|
74
72
|
builtinTools?: Tool[];
|
|
75
73
|
/** Resolve one `${NAME}` reference; the browser prompts for a missing secret. Defaults to the vault. */
|
|
76
74
|
resolveSecret?(ref: string, vault: Vault): Promise<string>;
|
|
@@ -183,6 +181,9 @@ export function assembleMachine(opts: AssembleOptions): AssembledMachine {
|
|
|
183
181
|
// registers its own (forwarding to its parent) over it — and unloading that reverts to refusing, which
|
|
184
182
|
// is the honest answer for a job with no channel. Late-bound, as `services` is built below. It writes
|
|
185
183
|
// through the shared runner, the one writer of the sessions in `services.sessions`.
|
|
184
|
+
// Not a host-supplied set: an embedder with a service of its own registers it on the returned
|
|
185
|
+
// `services` before `loaded()`, which is the same moment and one mechanism rather than two. A `seed`
|
|
186
|
+
// option existed for the node `FunctionRunner`, and went with it when that became a plugin.
|
|
186
187
|
const seed: Partial<MatbotServices> = {
|
|
187
188
|
MediaStore: fileStore,
|
|
188
189
|
SessionAppender: createSessionAppender({
|
|
@@ -191,7 +192,6 @@ export function assembleMachine(opts: AssembleOptions): AssembledMachine {
|
|
|
191
192
|
notifier: () => services.Notifier,
|
|
192
193
|
isSubAgent: () => services.isSubAgent(),
|
|
193
194
|
}),
|
|
194
|
-
...opts.seed,
|
|
195
195
|
};
|
|
196
196
|
const serviceRegistry = new Map<string, unknown>(Object.entries(seed));
|
|
197
197
|
// The validator lookup is late-bound and read per call: one is registered by a plugin long after the
|
package/src/config/loader.ts
CHANGED
|
@@ -21,12 +21,6 @@ export interface MatbotConfig {
|
|
|
21
21
|
* config rather than data. A stored value always wins; `delete` reverts to the default here.
|
|
22
22
|
*/
|
|
23
23
|
defaultSettings?: ReadonlyMap<string, Readonly<Record<string, unknown>>>;
|
|
24
|
-
/**
|
|
25
|
-
* Longest a `tool_function` body may run synchronously — without awaiting — before it is stopped
|
|
26
|
-
* (`function_timeout_ms`). Absent ⇒ the host's default. `0` ⇒ no limit at all, for testing: bodies run
|
|
27
|
-
* directly, and a loop that never awaits freezes the process.
|
|
28
|
-
*/
|
|
29
|
-
functionTimeoutMs?: number;
|
|
30
24
|
}
|
|
31
25
|
|
|
32
26
|
function asString(v: YamlValue | undefined, label: string): string {
|
|
@@ -144,17 +138,6 @@ export function parseConfig(
|
|
|
144
138
|
}
|
|
145
139
|
}
|
|
146
140
|
|
|
147
|
-
const functionTimeoutRaw = doc['function_timeout_ms'];
|
|
148
|
-
let functionTimeoutMs: number | undefined;
|
|
149
|
-
if (functionTimeoutRaw !== undefined && functionTimeoutRaw !== null) {
|
|
150
|
-
functionTimeoutMs = asNumber(functionTimeoutRaw, 'function_timeout_ms');
|
|
151
|
-
// Refused rather than clamped, like maxRounds: a negative or fractional limit is a typo, and `0` already
|
|
152
|
-
// means something of its own.
|
|
153
|
-
if (!Number.isInteger(functionTimeoutMs) || functionTimeoutMs < 0) {
|
|
154
|
-
throw new Error(`Config: "function_timeout_ms" must be a non-negative integer (milliseconds; 0 removes the limit), got ${functionTimeoutMs}`);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
141
|
const prompt = typeof doc['prompt'] === 'string' ? doc['prompt'] : undefined;
|
|
159
142
|
const ephemeral = doc['ephemeral'] === true ? true : undefined;
|
|
160
143
|
const defaultProvider = typeof doc['default_provider'] === 'string' ? doc['default_provider'] : undefined;
|
|
@@ -168,7 +151,6 @@ export function parseConfig(
|
|
|
168
151
|
...(defaultProvider !== undefined ? { defaultProvider } : {}),
|
|
169
152
|
...(principal !== undefined ? { principal } : {}),
|
|
170
153
|
...(defaultSettings.size > 0 ? { defaultSettings } : {}),
|
|
171
|
-
...(functionTimeoutMs !== undefined ? { functionTimeoutMs } : {}),
|
|
172
154
|
};
|
|
173
155
|
}
|
|
174
156
|
|
package/src/index.ts
CHANGED
|
@@ -18,12 +18,12 @@ export type {
|
|
|
18
18
|
MissingSecretError, IncompatibleRuntimeError, NotAPluginError, PromptCancelledError, ReadOnlyError,
|
|
19
19
|
MediaRejectedError, InvalidSecretNameError, MatbotErrorKind,
|
|
20
20
|
} from '@matatbread/matbot-plugin-api';
|
|
21
|
-
export { applyCreateSecret, assertStorableKey, unreferenceableKey, REFERENCEABLE_KEY_RULE } from '@matatbread/matbot-plugin-api';
|
|
21
|
+
export { applyResolve, applyCreateSecret, assertStorableKey, unreferenceableKey, REFERENCEABLE_KEY_RULE } from '@matatbread/matbot-plugin-api';
|
|
22
22
|
// The `provider update` patch policy, alongside the vault's: written once in plugin-api so the hosts
|
|
23
23
|
// that apply one cannot differ on what a patch MEANS, and re-exported here so an app needs no direct
|
|
24
24
|
// plugin-api dependency to get it.
|
|
25
25
|
export { applyProviderPatch, patchedFields } from '@matatbread/matbot-plugin-api';
|
|
26
|
-
export { isTruncatedToolResult, notifyingStore, ItemChangeKind, RegistryChangeKind, SessionAppendKind, FUNCTION_TIMEOUT } from '@matatbread/matbot-plugin-api';
|
|
26
|
+
export { isTruncatedToolResult, notifyingStore, ItemChangeKind, RegistryChangeKind, SessionAppendKind, FUNCTION_TIMEOUT, permitted, refused, refusalMessage } from '@matatbread/matbot-plugin-api';
|
|
27
27
|
// The canonical `type: 'confirm'` answers. Same reason as the patch policy above: an app implementing a
|
|
28
28
|
// PromptFn (or a test standing one in) has to speak them, and must not hard-code the rendered label.
|
|
29
29
|
export { CONFIRM_YES, CONFIRM_NO } from '@matatbread/matbot-plugin-api';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A text-typed `document` arm, rendered as a plain text block.
|
|
3
|
+
*
|
|
4
|
+
* Every adapter needs this and none should use its protocol's document block for it. A document block
|
|
5
|
+
* buys a title and citation support on the one endpoint that natively implements it, and is DISCARDED
|
|
6
|
+
* by the Anthropic- and OpenAI-compatible shims most vendors front their models with: DeepSeek
|
|
7
|
+
* substitutes the literal "[Unsupported Document]" server-side, so the model is told a file is attached
|
|
8
|
+
* and shown nothing — strictly worse than either sending the bytes or refusing them at the boundary.
|
|
9
|
+
* A text block is the one content shape every endpoint implements, and for text it is lossless: the
|
|
10
|
+
* model reads the same characters either way.
|
|
11
|
+
*
|
|
12
|
+
* The name is folded into the framing because the alternative is a bare CSV dump with nothing saying
|
|
13
|
+
* it is a file, which is the other half of what went wrong — a model that cannot tell content from
|
|
14
|
+
* attachment goes looking for the attachment.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Structured-text types that are text without saying `text/`. A `+json`/`+xml` suffix (RFC 6839) is
|
|
19
|
+
* matched too, so `application/vnd.api+json` needs no entry.
|
|
20
|
+
*
|
|
21
|
+
* An unlisted type is NOT guessed at, `application/octet-stream` above all: the browser hands that over
|
|
22
|
+
* for any extension the OS could not place (`.yaml` on some installs), and sniffing it would mean
|
|
23
|
+
* deciding that binary is text on the strength of it happening to decode.
|
|
24
|
+
*/
|
|
25
|
+
const TEXTUAL: ReadonlySet<string> = new Set([
|
|
26
|
+
'application/json', 'application/ld+json', 'application/x-ndjson',
|
|
27
|
+
'application/xml', 'application/yaml', 'application/x-yaml',
|
|
28
|
+
'application/toml', 'application/csv', 'application/sql',
|
|
29
|
+
'application/javascript', 'application/x-javascript', 'application/ecmascript',
|
|
30
|
+
'application/x-sh', 'application/x-httpd-php', 'application/graphql',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
function isTextual(mimeType: string): boolean {
|
|
34
|
+
const base = mimeType.split(';')[0]!.trim().toLowerCase();
|
|
35
|
+
return base.startsWith('text/') || base.endsWith('+json') || base.endsWith('+xml') || TEXTUAL.has(base);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The text of a document arm that should be inlined as text, or null to leave the adapter's own
|
|
40
|
+
* degradation in place (a PDF, an unrecognised type, or bytes that are not valid UTF-8 — a mislabelled
|
|
41
|
+
* binary must not reach a prompt as mojibake, so the decode is `fatal`).
|
|
42
|
+
*
|
|
43
|
+
* `atob` yields one byte per char, so re-widen through TextDecoder rather than trusting it for anything
|
|
44
|
+
* outside ASCII.
|
|
45
|
+
*/
|
|
46
|
+
export function textDocument(doc: { mimeType: string; data: string; name?: string | undefined }): string | null {
|
|
47
|
+
if (!isTextual(doc.mimeType)) return null;
|
|
48
|
+
let text: string;
|
|
49
|
+
try {
|
|
50
|
+
text = new TextDecoder('utf-8', { fatal: true })
|
|
51
|
+
.decode(Uint8Array.from(atob(doc.data), ch => ch.charCodeAt(0)));
|
|
52
|
+
} catch { return null; }
|
|
53
|
+
const label = doc.name ?? doc.mimeType;
|
|
54
|
+
return `--- ${label} ---\n${text}\n--- end of ${label} ---`;
|
|
55
|
+
}
|
package/src/registry.ts
CHANGED
|
@@ -60,7 +60,7 @@ async function resolveToolCollision(
|
|
|
60
60
|
): Promise<boolean> {
|
|
61
61
|
const owner = existingOwner !== undefined ? `"${existingOwner}"` : 'a built-in';
|
|
62
62
|
const label = `Tool \`"${toolName}"\` is already registered by **${owner}**. Overwrite it with the one from **"${incomingOwner}"**?`;
|
|
63
|
-
const
|
|
63
|
+
const decision = await (services.PermissionGate ?? askPermissionGate).decide({
|
|
64
64
|
gate: 'tools.overwrite',
|
|
65
65
|
subject: toolName,
|
|
66
66
|
// The incoming OWNER rides in the label rather than in a field of its own: a single `subject`
|
|
@@ -73,9 +73,10 @@ async function resolveToolCollision(
|
|
|
73
73
|
// changed hands" is exactly the report this line answers. A policy that overwrites after ASKING
|
|
74
74
|
// needs no warning — the user just saw the question.
|
|
75
75
|
if (prompt === undefined) {
|
|
76
|
-
console.warn(`[matbot] ${label} — non-interactive
|
|
76
|
+
console.warn(`[matbot] ${label} — non-interactive (${decision.source}), `
|
|
77
|
+
+ `${decision.allowed ? 'overwriting' : 'keeping the existing tool'}.`);
|
|
77
78
|
}
|
|
78
|
-
return allowed;
|
|
79
|
+
return decision.allowed;
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
// ── Version check ─────────────────────────────────────────────────────────────
|
package/src/runner.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
PermissionGate,
|
|
6
6
|
} from './types.js';
|
|
7
7
|
import type { MatbotPlugin } from './plugin.js';
|
|
8
|
-
import type { ToolPresenter, SessionAppender } from '@matatbread/matbot-plugin-api';
|
|
8
|
+
import type { ToolPresenter, SessionAppender, TurnToolSource } from '@matatbread/matbot-plugin-api';
|
|
9
9
|
import { recordSpan, recordUsage, withUsageSite, scopeIterable } from '@matatbread/matbot-plugin-api/host';
|
|
10
10
|
import { HookRegistry } from './hooks.js';
|
|
11
11
|
import { appendMessage, createMessage } from './session.js';
|
|
@@ -83,6 +83,38 @@ const INTERRUPTED_TOOL_RESULT = {
|
|
|
83
83
|
error: 'Tool call interrupted before completion — the turn was interrupted while it was running. It may not have run to completion, and any side effect may or may not have occurred. Re-run it only if you still need its result.',
|
|
84
84
|
} as const;
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The turn tools `screen` offered (see {@link TurnToolSource}), as they stand for this round. Asked every
|
|
88
|
+
* round because the turn itself changes the session they are derived from. A name the registry (or the
|
|
89
|
+
* turn-start snapshot) already holds is dropped: the registered tool keeps it, since shadowing one is a
|
|
90
|
+
* gated decision and a hook must not be a way round it. `warned` keeps that to one line per name a turn.
|
|
91
|
+
* A source that throws is skipped, like a throwing hook — it must not take the turn down with it.
|
|
92
|
+
*/
|
|
93
|
+
async function resolveTurnTools(
|
|
94
|
+
sources: readonly TurnToolSource[],
|
|
95
|
+
session: Session,
|
|
96
|
+
taken: (name: string) => boolean,
|
|
97
|
+
warned: Set<string>,
|
|
98
|
+
): Promise<Map<string, Tool>> {
|
|
99
|
+
const out = new Map<string, Tool>();
|
|
100
|
+
for (const source of sources) {
|
|
101
|
+
let offered: readonly Tool[];
|
|
102
|
+
try { offered = await source(session); }
|
|
103
|
+
catch (e) { console.warn(`[runner] a turn tool source failed: ${e instanceof Error ? e.message : String(e)}`); continue; }
|
|
104
|
+
for (const tool of offered) {
|
|
105
|
+
if (taken(tool.name)) {
|
|
106
|
+
if (!warned.has(tool.name)) {
|
|
107
|
+
warned.add(tool.name);
|
|
108
|
+
console.warn(`[runner] turn tool "${tool.name}" dropped: a registered tool has that name.`);
|
|
109
|
+
}
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!out.has(tool.name)) out.set(tool.name, tool);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
86
118
|
export interface RunSessionOpts {
|
|
87
119
|
session: Session;
|
|
88
120
|
config: RunConfig;
|
|
@@ -160,6 +192,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
160
192
|
async createSecret() { throw new Error('No vault configured'); },
|
|
161
193
|
async writeSecret() { throw new Error('No vault configured'); },
|
|
162
194
|
hasKey() { return false; },
|
|
195
|
+
async readSecret() { return undefined; },
|
|
163
196
|
async resolve(ref: string) { return ref; },
|
|
164
197
|
scrub(text: string) { return text; },
|
|
165
198
|
};
|
|
@@ -238,6 +271,11 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
238
271
|
// Returns whether anything was claimed. What the caller does with that is the part that legitimately
|
|
239
272
|
// differs — proceed, abort the in-flight provider call, or re-run the loop — so it stays at the site.
|
|
240
273
|
const deferred = screen.deferred;
|
|
274
|
+
|
|
275
|
+
// Re-resolved at the top of every round (below), so a tool defined in one round is callable in the next.
|
|
276
|
+
const turnToolSources = screen.tools;
|
|
277
|
+
const turnToolWarned = new Set<string>();
|
|
278
|
+
let turnTools = new Map<string, Tool>();
|
|
241
279
|
async function* claimVerdicts(): AsyncGenerator<TurnEvent, boolean, undefined> {
|
|
242
280
|
if (deferred.length === 0) return false;
|
|
243
281
|
const ephemeralC: MessageContent[] = [];
|
|
@@ -325,6 +363,11 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
325
363
|
// discarded. This is the no-waste path — the verdict landed before we spent any tokens.
|
|
326
364
|
yield* claimVerdicts();
|
|
327
365
|
|
|
366
|
+
if (turnToolSources.length > 0) {
|
|
367
|
+
turnTools = await resolveTurnTools(turnToolSources, session,
|
|
368
|
+
name => tools.has(name) || (opts.toolRegistry?.resolve(name) ?? null) !== null, turnToolWarned);
|
|
369
|
+
}
|
|
370
|
+
|
|
328
371
|
const pendingCalls: Array<{ id: string; name: string; input: unknown; meta?: ProviderMeta;
|
|
329
372
|
truncated?: { bytes: number; stopReason?: string } }> = [];
|
|
330
373
|
const assistantParts: MessageContent[] = [];
|
|
@@ -381,10 +424,13 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
381
424
|
// tools stay callable — execution resolves against the live registry (opts.toolRegistry), not this.
|
|
382
425
|
// Scoped to the round: a presenter may itself run completions (deriving a tool's search terms), and
|
|
383
426
|
// that spend belongs to the round that asked for it — it is neither a tool call nor a hook.
|
|
384
|
-
|
|
427
|
+
// Turn tools go AFTER the presenter, never through it: they are few, made for this session, and
|
|
428
|
+
// absent from the registry a presenter's own search reads — one it windowed out could not come back.
|
|
429
|
+
const presented = opts.toolPresenter
|
|
385
430
|
? await withUsageSite({ kind: 'round', round },
|
|
386
431
|
() => opts.toolPresenter!.present([...tools.values()], { session, provider: config.provider }))
|
|
387
432
|
: [...tools.values()];
|
|
433
|
+
const advertised = turnTools.size > 0 ? [...presented, ...turnTools.values()] : presented;
|
|
388
434
|
// Per-call signal linked to the turn signal: an in-situ restart aborts THIS provider call (callAc)
|
|
389
435
|
// to cancel the in-flight request and stop backend generation, without aborting the whole turn.
|
|
390
436
|
const callAc = new AbortController();
|
|
@@ -404,6 +450,9 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
404
450
|
case 'thinking':
|
|
405
451
|
yield { type: 'thinking', delta: ev.delta, traceId };
|
|
406
452
|
break;
|
|
453
|
+
case 'progress':
|
|
454
|
+
yield { type: 'progress', pct: ev.pct, ...(ev.message !== undefined ? { message: ev.message } : {}), traceId };
|
|
455
|
+
break;
|
|
407
456
|
case 'thinking-block':
|
|
408
457
|
assistantParts.push({ type: 'thinking', thinking: ev.thinking, signature: ev.signature });
|
|
409
458
|
break;
|
|
@@ -525,7 +574,8 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
525
574
|
for (const [callIdx, tc] of pendingCalls.entries()) {
|
|
526
575
|
yield { type: 'tool:start', callId: tc.id, name: tc.name, input: tc.input, traceId };
|
|
527
576
|
|
|
528
|
-
const tool = opts.toolRegistry !== undefined ? opts.toolRegistry.resolve(tc.name) : tools.get(tc.name)
|
|
577
|
+
const tool = (opts.toolRegistry !== undefined ? opts.toolRegistry.resolve(tc.name) : tools.get(tc.name))
|
|
578
|
+
?? turnTools.get(tc.name);
|
|
529
579
|
if (!tool) {
|
|
530
580
|
const err = { error: `Unknown tool: ${tc.name}` };
|
|
531
581
|
toolResults.push({ type: 'tool-result', id: tc.id, result: err, isError: true });
|
|
@@ -622,11 +672,12 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
622
672
|
// default, so a gate handed it could never tell "no human is reachable" from "a human answered
|
|
623
673
|
// with the default" — and would get the fallback back one layer too late to reason about.
|
|
624
674
|
// `ToolContext.prompt` keeps the stand-in exactly as before; only the gate sees the truth.
|
|
625
|
-
...bindGate(opts.permissionGate,
|
|
675
|
+
...bindGate(opts.permissionGate, tool, opts.prompt, opts.toolRegistry ?? { resolve: n => tools.get(n) ?? null }),
|
|
626
676
|
...(opts.workdir !== undefined ? { workdir: opts.workdir } : {}),
|
|
627
677
|
...(opts.configPath !== undefined ? { configPath: opts.configPath } : {}),
|
|
628
678
|
...(opts.files !== undefined ? { files: opts.files } : {}),
|
|
629
679
|
...(opts.appender !== undefined ? { appender: opts.appender } : {}),
|
|
680
|
+
...(turnTools.size > 0 ? { turnTools } : {}),
|
|
630
681
|
};
|
|
631
682
|
|
|
632
683
|
// Iterated by hand rather than with `for await`, so the read can be bounded once the turn is
|
package/src/security/vault.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { Vault } from '@matatbread/matbot-plugin-api';
|
|
2
|
-
import {
|
|
2
|
+
import { applyResolve, applyCreateSecret, assertStorableKey, unreferenceableKey } from '@matatbread/matbot-plugin-api';
|
|
3
3
|
|
|
4
|
-
const REF_RE = /\$\{([^}]+)\}/g;
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* Default vault implementation: a single flat namespace of secrets keyed by name.
|
|
@@ -58,19 +57,12 @@ export class VaultImpl implements Vault {
|
|
|
58
57
|
return undefined;
|
|
59
58
|
}
|
|
60
59
|
|
|
61
|
-
async
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const result = ref.replace(REF_RE, (_, name: string) => {
|
|
65
|
-
const value = this.store.get(name);
|
|
66
|
-
if (value === undefined) { errors.push(name); return ''; }
|
|
67
|
-
return value;
|
|
68
|
-
});
|
|
60
|
+
async readSecret(name: string): Promise<string | undefined> {
|
|
61
|
+
return this.store.get(name);
|
|
62
|
+
}
|
|
69
63
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
return result;
|
|
64
|
+
resolve(ref: string): Promise<string> {
|
|
65
|
+
return applyResolve(this, ref);
|
|
74
66
|
}
|
|
75
67
|
|
|
76
68
|
scrub(text: string): string {
|
package/src/session-runner.ts
CHANGED
|
@@ -108,7 +108,7 @@ const DEFAULT_STEER_NUDGE: MessageContent[] = [{
|
|
|
108
108
|
// "how's it going?" mid-task sees a history that ends a turn early and answers as though nothing were
|
|
109
109
|
// running. Names the running request so the reply can be about it, and says what happens to the reply.
|
|
110
110
|
const PARALLEL_FRAMING_CAP = 2_000;
|
|
111
|
-
const parallelFraming = (running: string): UserContent => ({
|
|
111
|
+
const parallelFraming = (running: string): Extract<UserContent, { type: 'text' }> => ({
|
|
112
112
|
type: 'text',
|
|
113
113
|
origin: 'robo',
|
|
114
114
|
text: 'A separate turn of this conversation is still working on the request below, and you cannot see its '
|
|
@@ -118,11 +118,32 @@ const parallelFraming = (running: string): UserContent => ({
|
|
|
118
118
|
+ '\n</running-request>',
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
-
// A parallel turn's
|
|
122
|
-
//
|
|
121
|
+
// A parallel turn's span as it can be placed into shared history: trimmed back to the last point at
|
|
122
|
+
// which every tool-call in it has its result. An abort (or a failure mid-round) leaves a trailing
|
|
123
|
+
// tool-call with nothing answering it, and an unpaired call is not valid wire for any provider — it
|
|
124
|
+
// would break not just this placement but every later turn of the session.
|
|
125
|
+
const closedSpan = (msgs: Message[]): Message[] => {
|
|
126
|
+
const open = new Set<string>();
|
|
127
|
+
let closed = 0;
|
|
128
|
+
for (const [i, m] of msgs.entries()) {
|
|
129
|
+
for (const c of m.content) {
|
|
130
|
+
if (c.type === 'tool-call') open.add(c.id);
|
|
131
|
+
else if (c.type === 'tool-result') open.delete(c.id);
|
|
132
|
+
}
|
|
133
|
+
if (open.size === 0) closed = i + 1;
|
|
134
|
+
}
|
|
135
|
+
return msgs.slice(0, closed);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// A parallel turn's write-back: its submission and everything the turn produced, ready to place. Built
|
|
139
|
+
// once, when the parallel turn settles; placed by whoever holds the session at that moment (see
|
|
140
|
+
// `merges`).
|
|
123
141
|
interface ParallelReply {
|
|
124
142
|
traceId: string;
|
|
125
|
-
|
|
143
|
+
/** The turn's head first, then its whole span — thinking, tool calls, their results, its markers and
|
|
144
|
+
* its final reply. Non-empty by construction, which is what keeps `messages[0]` the head: every
|
|
145
|
+
* placement path dedupes on that id. */
|
|
146
|
+
messages: [Message, ...Message[]];
|
|
126
147
|
/** The id of the message this reply's copy was cut AT — the running turn's head when it was submitted,
|
|
127
148
|
* captured then. The pair belongs immediately ahead of it, because that is where the history the reply
|
|
128
149
|
* was generated against ended. Absent ⇒ the copy was cut at the end of the session (nothing was
|
|
@@ -881,12 +902,12 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
881
902
|
|
|
882
903
|
/**
|
|
883
904
|
* A parallel turn: the submission runs at once on a nested runner over a private copy of the session's
|
|
884
|
-
* completed turns, and
|
|
905
|
+
* completed turns, and its submission plus everything it produced come back (`ParallelReply`). The copy ends where the
|
|
885
906
|
* running turn's own messages begin (`head`), plus any replies already placed into that turn but not
|
|
886
907
|
* yet committed (`interjected`) — both captured at submit, before any await could outlive the turn.
|
|
887
908
|
*
|
|
888
|
-
* Its events are forwarded under its own traceId as progress
|
|
889
|
-
* the copy
|
|
909
|
+
* Its events are forwarded under its own traceId as progress; what persists is the span the write-back
|
|
910
|
+
* carries, read from the copy once the turn has settled. A nested runner rather than `runSession` so the turn is an ordinary
|
|
890
911
|
* one — screen, followup, accounting, media — with nothing re-implemented.
|
|
891
912
|
*/
|
|
892
913
|
const runParallel = async (
|
|
@@ -910,6 +931,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
910
931
|
const have = new Set(prefix.map(m => m.id));
|
|
911
932
|
const copy = { ...stored, messages: [...prefix, ...p.interjected.filter(m => !have.has(m.id))] };
|
|
912
933
|
const running = p.head?.request ?? '';
|
|
934
|
+
const framing = running ? parallelFraming(running) : undefined;
|
|
913
935
|
|
|
914
936
|
const store = new MemoryStore<Session>();
|
|
915
937
|
await store.set(id, copy);
|
|
@@ -941,10 +963,21 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
941
963
|
const msgs = final?.messages ?? [];
|
|
942
964
|
const hIdx = msgs.findIndex(m => m.role === 'user' && m.traceId === p.traceId);
|
|
943
965
|
const qHead = msgs[hIdx];
|
|
944
|
-
//
|
|
945
|
-
//
|
|
946
|
-
|
|
947
|
-
|
|
966
|
+
// The turn's WHOLE span crosses back, not just its final text: thinking, tool calls, their results
|
|
967
|
+
// and any markers. A history holding only the answer makes everything a parallel turn actually did
|
|
968
|
+
// unrecoverable the moment the live stream is gone — `determine_provenance` reads a real tool result
|
|
969
|
+
// as fabricated, and so does a person scrolling back. The span is placed exactly where the copy was
|
|
970
|
+
// cut, so it sits in the history it was generated against, and `closedSpan` is what keeps it valid
|
|
971
|
+
// wire. Thinking rides along persisted but unsubmitted (every adapter elides a historical block),
|
|
972
|
+
// and a foreign round-trip token is the adapters' to drop by `providerName`, which these messages
|
|
973
|
+
// carry — so nothing here depends on which provider ran the turn.
|
|
974
|
+
const span = hIdx >= 0 ? closedSpan(msgs.slice(hIdx + 1)) : [];
|
|
975
|
+
// Answered iff the span ENDS with assistant text. Not "contains some": a turn aborted after
|
|
976
|
+
// "let me check that" has said nothing, and a span ending on a tool result needs the note after it
|
|
977
|
+
// to close the turn on an assistant message.
|
|
978
|
+
const tail = span[span.length - 1];
|
|
979
|
+
const answer = tail?.role === 'assistant'
|
|
980
|
+
? tail.content.filter(c => c.type === 'text' && c.text.trim() !== '') : [];
|
|
948
981
|
const ended = rec.replay.findLast(e => e.type === 'done' || e.type === 'aborted' || e.type === 'error' || e.type === 'cancelled');
|
|
949
982
|
const reason = ended?.type === 'aborted' ? `was stopped (${ended.reason})`
|
|
950
983
|
: ended?.type === 'cancelled' ? 'was stopped before it started'
|
|
@@ -954,14 +987,19 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
954
987
|
traceId: p.traceId,
|
|
955
988
|
...(p.head !== undefined ? { head: p.head.id } : {}),
|
|
956
989
|
messages: [
|
|
957
|
-
// The copy's own head, carrying its accounting,
|
|
990
|
+
// The copy's own head, carrying its accounting, with the framing block taken back OFF — that
|
|
991
|
+
// block is this turn's own context and not something the person wrote. Subtracted rather than
|
|
992
|
+
// replaced with `p.content` wholesale: a `contextual` trigger folds its correction DURABLY onto
|
|
993
|
+
// the head, and overwriting the content dropped exactly that, so the correction informed the
|
|
994
|
+
// answer and then vanished from the history the answer sits in.
|
|
958
995
|
qHead !== undefined
|
|
959
|
-
? { ...qHead, content:
|
|
996
|
+
? { ...qHead, content: qHead.content.filter(c => !(framing !== undefined && c.type === 'text'
|
|
997
|
+
&& c.origin === framing.origin && c.text === framing.text)) }
|
|
960
998
|
: createMessage({ role: 'user', content: p.content, traceId: p.traceId, providerName: p.provider }),
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
999
|
+
...span,
|
|
1000
|
+
...(answer.length > 0 ? [] : [createMessage({
|
|
1001
|
+
role: 'assistant', content: [{ type: 'text', text: `(No reply — this message's parallel turn ${reason}.)` }],
|
|
1002
|
+
traceId: p.traceId, providerName: p.provider })]),
|
|
965
1003
|
],
|
|
966
1004
|
};
|
|
967
1005
|
} catch (e) {
|