@genesislcap/ai-assistant 14.469.0 → 14.470.0
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/dist/ai-assistant.api.json +272 -3
- package/dist/ai-assistant.d.ts +95 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +34 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +1 -1
- package/dist/dts/config/config.d.ts +42 -2
- package/dist/dts/config/config.d.ts.map +1 -1
- package/dist/dts/config/define-stateful-agent.d.ts +14 -1
- package/dist/dts/config/define-stateful-agent.d.ts.map +1 -1
- package/dist/dts/main/main.d.ts +3 -0
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/state/debug-event-log.d.ts +3 -1
- package/dist/dts/state/debug-event-log.d.ts.map +1 -1
- package/dist/dts/utils/condense-history.d.ts +10 -0
- package/dist/dts/utils/condense-history.d.ts.map +1 -1
- package/dist/dts/utils/strip-agent-handlers.d.ts +2 -1
- package/dist/dts/utils/strip-agent-handlers.d.ts.map +1 -1
- package/dist/esm/components/chat-driver/chat-driver.js +87 -13
- package/dist/esm/components/chat-driver/chat-driver.test.js +95 -1
- package/dist/esm/config/define-stateful-agent.js +18 -0
- package/dist/esm/state/debug-event-log.js +1 -1
- package/dist/esm/utils/condense-history.js +11 -1
- package/dist/esm/utils/condense-history.test.js +43 -2
- package/dist/esm/utils/strip-agent-handlers.js +2 -1
- package/package.json +16 -16
- package/src/components/chat-driver/chat-driver.test.ts +115 -1
- package/src/components/chat-driver/chat-driver.ts +121 -18
- package/src/config/config.ts +46 -1
- package/src/config/define-stateful-agent.ts +47 -0
- package/src/state/debug-event-log.ts +5 -2
- package/src/utils/condense-history.test.ts +65 -2
- package/src/utils/condense-history.ts +18 -1
- package/src/utils/strip-agent-handlers.ts +2 -1
package/src/config/config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
CachePolicy,
|
|
2
3
|
ChatInputDuringExecutionMode,
|
|
3
4
|
ChatMessage,
|
|
4
5
|
ChatToolChoice,
|
|
@@ -6,7 +7,7 @@ import type {
|
|
|
6
7
|
ChatToolHandlers,
|
|
7
8
|
} from '@genesislcap/foundation-ai';
|
|
8
9
|
|
|
9
|
-
export type { ChatInputDuringExecutionMode, ChatToolChoice };
|
|
10
|
+
export type { CachePolicy, ChatInputDuringExecutionMode, ChatToolChoice };
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Context passed to `onActivate` / `onDeactivate` lifecycle hooks on an agent.
|
|
@@ -116,6 +117,29 @@ export type ToolChoiceInput =
|
|
|
116
117
|
| ChatToolChoice
|
|
117
118
|
| ((ctx: SystemPromptContext) => ChatToolChoice | Promise<ChatToolChoice>);
|
|
118
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Prompt-cache policy for an agent. Either a static `CachePolicy` (resolved once) or a
|
|
122
|
+
* function resolved each tool-loop iteration — pick the function form to vary it by current state
|
|
123
|
+
* (e.g. cache `'history'` in a long generating phase, `'default'` in short low-touch ones). Omit
|
|
124
|
+
* to request no caching. Resolved and applied the same way as {@link TemperatureInput}.
|
|
125
|
+
*
|
|
126
|
+
* @beta
|
|
127
|
+
*/
|
|
128
|
+
export type CachePolicyInput =
|
|
129
|
+
| CachePolicy
|
|
130
|
+
| ((ctx: SystemPromptContext) => CachePolicy | Promise<CachePolicy>);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Per-turn tail context for an agent — volatile content (current file/spec, diagnostics, live
|
|
134
|
+
* state) injected at the tail of every model-call so the model sees it without invalidating the
|
|
135
|
+
* cached prefix. Either a static string or a function resolved each tool-loop iteration. **Provide
|
|
136
|
+
* raw content** — the framework frames it (wraps it in a `<system-reminder>` marker) so the model
|
|
137
|
+
* reads it as ambient context, not user input. Omit, or return an empty string, for none.
|
|
138
|
+
*
|
|
139
|
+
* @beta
|
|
140
|
+
*/
|
|
141
|
+
export type TailContextInput = string | ((ctx: SystemPromptContext) => string | Promise<string>);
|
|
142
|
+
|
|
119
143
|
/**
|
|
120
144
|
* Context passed to an agent's `onUnresolvedTool` hook when the model calls a
|
|
121
145
|
* tool the driver cannot dispatch.
|
|
@@ -258,6 +282,27 @@ interface BaseAgentConfig {
|
|
|
258
282
|
* @beta
|
|
259
283
|
*/
|
|
260
284
|
toolChoice?: ToolChoiceInput;
|
|
285
|
+
/**
|
|
286
|
+
* Prompt-cache policy for this agent — whether to emit cache breakpoints and how far up the
|
|
287
|
+
* prefix. Either a static value or a function resolved each tool-loop iteration (vary by state).
|
|
288
|
+
* Resolved and applied the same way as {@link BaseAgentConfig.temperature}. Omit to request no
|
|
289
|
+
* caching. Provider-neutral — see {@link CachePolicyInput} / `CachePolicy` for the
|
|
290
|
+
* Anthropic-vs-Gemini behaviour. See {@link CachePolicyInput}.
|
|
291
|
+
*
|
|
292
|
+
* @beta
|
|
293
|
+
*/
|
|
294
|
+
cachePolicy?: CachePolicyInput;
|
|
295
|
+
/**
|
|
296
|
+
* Volatile per-turn context injected at the tail of every model-call (after the history and any
|
|
297
|
+
* cache breakpoint), so it is seen each turn without busting the cached prefix — the place to put
|
|
298
|
+
* the changing parts (current file/spec, diagnostics, live state) that would otherwise
|
|
299
|
+
* destabilize a cached system prompt. Provide *raw* content; the framework frames it (wraps it in
|
|
300
|
+
* a `<system-reminder>` marker). Resolved per turn like {@link BaseAgentConfig.temperature}. See
|
|
301
|
+
* {@link TailContextInput}.
|
|
302
|
+
*
|
|
303
|
+
* @beta
|
|
304
|
+
*/
|
|
305
|
+
tailContext?: TailContextInput;
|
|
261
306
|
/**
|
|
262
307
|
* Optional hook consulted when the model calls a tool the driver cannot
|
|
263
308
|
* dispatch — either a *stale* tool (advertised earlier this activation but
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
CachePolicy,
|
|
2
3
|
ChatMessage,
|
|
3
4
|
ChatToolChoice,
|
|
4
5
|
ChatToolDefinition,
|
|
@@ -8,11 +9,13 @@ import { TOOL_FOLD_SYMBOL } from '../utils/tool-fold';
|
|
|
8
9
|
import type {
|
|
9
10
|
AgentConfig,
|
|
10
11
|
AgentLifecycleContext,
|
|
12
|
+
CachePolicyInput,
|
|
11
13
|
ChatInputDuringExecutionMode,
|
|
12
14
|
ManualSelectionConfig,
|
|
13
15
|
ProviderInput,
|
|
14
16
|
SystemPromptContext,
|
|
15
17
|
SystemPromptInput,
|
|
18
|
+
TailContextInput,
|
|
16
19
|
TemperatureInput,
|
|
17
20
|
ToolChoiceInput,
|
|
18
21
|
ToolDefinitionsInput,
|
|
@@ -155,6 +158,23 @@ export interface StatefulAgentInit<S> {
|
|
|
155
158
|
| ChatToolChoice
|
|
156
159
|
| ((ctx: StatefulAgentContext<S>) => ChatToolChoice | Promise<ChatToolChoice>);
|
|
157
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Prompt-cache policy. Either a static `CachePolicy` or a function resolved each tool-loop
|
|
163
|
+
* iteration with the current `state` — pick the function form to vary it per machine state
|
|
164
|
+
* (e.g. `'history'` in a long generating phase, `'default'` in short low-touch ones). Omit to
|
|
165
|
+
* request no caching.
|
|
166
|
+
*/
|
|
167
|
+
cachePolicy?:
|
|
168
|
+
| CachePolicy
|
|
169
|
+
| ((ctx: StatefulAgentContext<S>) => CachePolicy | Promise<CachePolicy>);
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Volatile per-turn tail context (current file/spec, diagnostics, live state). Either a static
|
|
173
|
+
* string or a function resolved each tool-loop iteration with the current `state`. Provide raw
|
|
174
|
+
* content; the framework frames it. Omit, or return an empty string, for none.
|
|
175
|
+
*/
|
|
176
|
+
tailContext?: string | ((ctx: StatefulAgentContext<S>) => string | Promise<string>);
|
|
177
|
+
|
|
158
178
|
/**
|
|
159
179
|
* Hook consulted when the model calls a tool that isn't dispatchable in the
|
|
160
180
|
* current state — a *stale* tool (valid in an earlier state of this agent) or
|
|
@@ -344,6 +364,31 @@ export function defineStatefulAgent<S>(opts: StatefulAgentInit<S>): AgentConfig
|
|
|
344
364
|
}
|
|
345
365
|
: opts.toolChoice;
|
|
346
366
|
|
|
367
|
+
const wrappedCachePolicy: CachePolicyInput | undefined =
|
|
368
|
+
typeof opts.cachePolicy === 'function'
|
|
369
|
+
? async (ctx: SystemPromptContext) => {
|
|
370
|
+
if (!state) {
|
|
371
|
+
throw new Error(`Stateful agent "${opts.name}" cachePolicy called before init`);
|
|
372
|
+
}
|
|
373
|
+
return (
|
|
374
|
+
opts.cachePolicy as (ctx: StatefulAgentContext<S>) => CachePolicy | Promise<CachePolicy>
|
|
375
|
+
)({ ...ctx, state });
|
|
376
|
+
}
|
|
377
|
+
: opts.cachePolicy;
|
|
378
|
+
|
|
379
|
+
const wrappedTailContext: TailContextInput | undefined =
|
|
380
|
+
typeof opts.tailContext === 'function'
|
|
381
|
+
? async (ctx: SystemPromptContext) => {
|
|
382
|
+
if (!state) {
|
|
383
|
+
throw new Error(`Stateful agent "${opts.name}" tailContext called before init`);
|
|
384
|
+
}
|
|
385
|
+
return (opts.tailContext as (ctx: StatefulAgentContext<S>) => string | Promise<string>)({
|
|
386
|
+
...ctx,
|
|
387
|
+
state,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
: opts.tailContext;
|
|
391
|
+
|
|
347
392
|
// `onUnresolvedTool` threads `state` in like the resolvers above, but unlike
|
|
348
393
|
// them returns `undefined` before init rather than throwing: it fires on the
|
|
349
394
|
// tool-dispatch path, so a pre-init call must degrade to the framework
|
|
@@ -370,6 +415,8 @@ export function defineStatefulAgent<S>(opts: StatefulAgentInit<S>): AgentConfig
|
|
|
370
415
|
provider: wrappedProvider,
|
|
371
416
|
temperature: wrappedTemperature,
|
|
372
417
|
toolChoice: wrappedToolChoice,
|
|
418
|
+
cachePolicy: wrappedCachePolicy,
|
|
419
|
+
tailContext: wrappedTailContext,
|
|
373
420
|
onUnresolvedTool: wrappedOnUnresolvedTool,
|
|
374
421
|
|
|
375
422
|
onActivate: async (ctx: AgentLifecycleContext) => {
|
|
@@ -199,13 +199,16 @@ export function recordMetaEvent(
|
|
|
199
199
|
* - `unknown-tool-limit` — the model repeatedly called tools it couldn't dispatch,
|
|
200
200
|
* whether hallucinated or stale (real earlier, retired now).
|
|
201
201
|
* - `max-iterations` — the tool loop hit its iteration cap.
|
|
202
|
+
* - `response-truncated` — a turn stopped at the provider's output-token cap with an
|
|
203
|
+
* incomplete tool call; deterministic, so it bails without retry.
|
|
202
204
|
*/
|
|
203
205
|
export type TurnFailureReason =
|
|
204
206
|
| 'exception'
|
|
205
207
|
| 'malformed-function-call'
|
|
206
208
|
| 'empty-response'
|
|
207
209
|
| 'unknown-tool-limit'
|
|
208
|
-
| 'max-iterations'
|
|
210
|
+
| 'max-iterations'
|
|
211
|
+
| 'response-truncated';
|
|
209
212
|
|
|
210
213
|
/**
|
|
211
214
|
* Record a turn-ending failure (`turn.error`, importance `high`). The `reason`
|
|
@@ -301,7 +304,7 @@ export const DEBUG_LOG_README: readonly string[] = [
|
|
|
301
304
|
"kind:'turn'.`agentSnapshot` — the active agent's own view of its internal state, captured at that turn. An agent opts into this by exposing a `getDebugSnapshot()` that returns JSON-serializable per-state info; stateful/flow agents wire it automatically, so you can watch a flow advance turn-by-turn (e.g. current step, cursor, collected fields, pending changes). Absent for agents that don't expose one.",
|
|
302
305
|
"kind:'event' — a meta/lifecycle event. `type` names it (see below); `detail` carries structured data. `detail.placement` is the emitting UI instance: 'bubble' (collapsed), 'panel' (popped-out), or 'standalone'.",
|
|
303
306
|
"Each 'event' also has an `importance`: 'high' (failures/limits — turn.error, tool.failed, subagent.failed, file.read-failed, suggestions.failed, context.threshold-crossed), 'normal' (session flow — connects, turns, retries, handoffs, agent/provider changes, interactions, sub-agent start/complete), or 'low' (skippable UI/bookkeeping noise — panel.toggled, attachment.added, driver.wired/unwired, context.updated, context.condensed). To skim, ignore importance:'low'; to triage a failure, filter to importance:'high' then read the nearby messages and turns. A 'high' turn.error is often preceded by one or more 'normal' turn.retry events for the same reason — read them together to see how many attempts were made before bailing. 'message' and 'turn' entries carry no importance — they are the substance, always read them.",
|
|
304
|
-
'Event types: assistant.connected/disconnected (mount + placement + whether the session was created or restored), assistant.popout/popin (window placement), driver.created/wired/unwired (which driver is live and why it stops/starts responding across a popout), state.changed (idle↔loading), turn.start/turn.end (turn boundary; turn.end carries durationMs), turn.retry (a recoverable in-turn retry — detail.reason plus attempt/maxAttempts; for malformed calls also finishMessage; for empty responses also the provider finishReason + thoughtsTokens + parts breakdown), turn.error (a turn failed or hit a guardrail — detail.reason is one of exception/malformed-function-call/empty-response/unknown-tool-limit/max-iterations, plus reason-specific diagnostics: attempts (for empty-response also finishReason + thoughtsTokens + a parts breakdown, distinguishing a thinking-only STOP from a truly empty turn), finishMessage, unknownTools (split into staleTools — real earlier this activation but retired by the current state or hidden behind an open exclusive fold — and hallucinatedTools — never advertised) + availableTools, iterations + limit, or name + message for exceptions), tool.failed (a tool threw), tool.unresolved (the model called a tool that could not be dispatched — detail.kind is folded/fold-hidden/stale/unknown, plus tool + agent and, for the counted kinds, the consecutive streak; the recurring lead-up to an unknown-tool-limit turn.error), subagent.started/completed/failed (the lifecycle of a `requestSubAgent` delegation — detail.agent names the sub-agent; these bracket the sub-agent turns that appear as kind:turn entries with an N-M `turnIndex`; subagent.failed also carries detail.reason, one of max_iterations/malformed_tool_call/empty_response/unknown_tool_limit/timeout), agent.handoff (routing; from=null is the initial activation), agent.pinned/unpinned (forced routing), provider.selected (model/provider for the upcoming turns), interaction.requested/resolved (blocking user widgets — explain quiet gaps; note that when a sub-agent opens a widget, detail.agent — and the agentName on the interaction message — is the HOST agent that owns the widget, NOT the sub-agent that asked, because widgets render and resolve on the host driver), context.updated/threshold-crossed (token + cost), context.condensed (a stale tool payload was collapsed out of the model-bound history by a `condenseWhen` declaration on the tool — detail.tool + toolCallId, target args|response, trigger (superseded:<key> or age:<n>), stubLen, and an estimated tokensSaved; stored history and this log keep the FULL payload, so the model-visible slice at any point is the full history minus the condensations recorded up to then), panel.toggled, attachment.added, file.read-failed, suggestions.failed.',
|
|
307
|
+
'Event types: assistant.connected/disconnected (mount + placement + whether the session was created or restored), assistant.popout/popin (window placement), driver.created/wired/unwired (which driver is live and why it stops/starts responding across a popout), state.changed (idle↔loading), turn.start/turn.end (turn boundary; turn.end carries durationMs), turn.retry (a recoverable in-turn retry — detail.reason plus attempt/maxAttempts; for malformed calls also finishMessage; for empty responses also the provider finishReason + thoughtsTokens + parts breakdown), turn.error (a turn failed or hit a guardrail — detail.reason is one of exception/malformed-function-call/empty-response/unknown-tool-limit/max-iterations/response-truncated, plus reason-specific diagnostics: attempts (for empty-response also finishReason + thoughtsTokens + a parts breakdown, distinguishing a thinking-only STOP from a truly empty turn), finishMessage, for response-truncated the model + maxTokens + outputTokens + tools, unknownTools (split into staleTools — real earlier this activation but retired by the current state or hidden behind an open exclusive fold — and hallucinatedTools — never advertised) + availableTools, iterations + limit, or name + message for exceptions), tool.failed (a tool threw), tool.unresolved (the model called a tool that could not be dispatched — detail.kind is folded/fold-hidden/stale/unknown, plus tool + agent and, for the counted kinds, the consecutive streak; the recurring lead-up to an unknown-tool-limit turn.error), subagent.started/completed/failed (the lifecycle of a `requestSubAgent` delegation — detail.agent names the sub-agent; these bracket the sub-agent turns that appear as kind:turn entries with an N-M `turnIndex`; subagent.failed also carries detail.reason, one of max_iterations/malformed_tool_call/empty_response/unknown_tool_limit/timeout/response_truncated), agent.handoff (routing; from=null is the initial activation), agent.pinned/unpinned (forced routing), provider.selected (model/provider for the upcoming turns), interaction.requested/resolved (blocking user widgets — explain quiet gaps; note that when a sub-agent opens a widget, detail.agent — and the agentName on the interaction message — is the HOST agent that owns the widget, NOT the sub-agent that asked, because widgets render and resolve on the host driver), context.updated/threshold-crossed (token + cost), context.condensed (a stale tool payload was collapsed out of the model-bound history by a `condenseWhen` declaration on the tool — detail.tool + toolCallId, target args|response, trigger (superseded:<key> or age:<n>), stubLen, and an estimated tokensSaved; stored history and this log keep the FULL payload, so the model-visible slice at any point is the full history minus the condensations recorded up to then), panel.toggled, attachment.added, file.read-failed, suggestions.failed.',
|
|
305
308
|
'Sub-agent meta events: a sub-agent\'s own turn.retry/turn.error/tool.failed/tool.unresolved events are merged into this same timeline, tagged with `detail.subAgent` — a `"<parent> › <sub-agent>"` breadcrumb that composes when nested (e.g. `"UI Builder › Planner › Grounding"`) — and interleaved by their original timestamps within the subagent.started→completed/failed bracket. These are the per-attempt/per-failure signals that do NOT appear among the sub-agent\'s (hoisted) messages: a malformed/empty attempt that gets retried produces no message, and the stale-vs-hallucinated split and streak counts live only on the event. A sub-agent\'s high-volume, message-derivable events (turn.start/turn.end, provider.selected, context.updated) are intentionally NOT merged — read its hoisted messages for model/tokens/cost and turn-by-turn activity, and the bracketing subagent.* events for the run\'s span.',
|
|
306
309
|
"`meta` holds context captured at export time: agentSummary (full agent configs), context (active model, token usage, session cost), activeDebugSnapshot (the active agent's `getDebugSnapshot()` taken fresh at export — reflects state NOW, which may have advanced beyond the last turn's agentSnapshot), debug (optional host-supplied debug state), host, and the export timestamp.",
|
|
307
310
|
'To debug a failure: find the last turn.error or tool.failed, then read upward for the user message, the turn(s), and the agent/provider/state events that led into it.',
|
|
@@ -28,21 +28,28 @@ const toolMsg = (toolCallId: string, content: string): ChatMessage => ({
|
|
|
28
28
|
|
|
29
29
|
const reg = (
|
|
30
30
|
policy: CondensePolicy,
|
|
31
|
-
clocks: { iteration?: number; turn?: number; activation?: number } = {},
|
|
31
|
+
clocks: { iteration?: number; turn?: number; activation?: number; phaseEpoch?: number } = {},
|
|
32
32
|
): RegisteredCondensePolicy => ({
|
|
33
33
|
policy,
|
|
34
34
|
iteration: clocks.iteration ?? 1,
|
|
35
35
|
turn: clocks.turn ?? 1,
|
|
36
36
|
activation: clocks.activation ?? 1,
|
|
37
|
+
phaseEpoch: clocks.phaseEpoch ?? 1,
|
|
37
38
|
});
|
|
38
39
|
|
|
39
40
|
/** A CondenseContext for the pure transform. Defaults fire nothing; override per test. */
|
|
40
41
|
const ctx = (
|
|
41
|
-
o: {
|
|
42
|
+
o: {
|
|
43
|
+
modelCall?: number;
|
|
44
|
+
turn?: number;
|
|
45
|
+
activationEnded?: (a: number) => boolean;
|
|
46
|
+
phaseEnded?: (e: number) => boolean;
|
|
47
|
+
} = {},
|
|
42
48
|
): CondenseContext => ({
|
|
43
49
|
modelCall: o.modelCall ?? 0,
|
|
44
50
|
turn: o.turn ?? 0,
|
|
45
51
|
activationEnded: o.activationEnded ?? (() => false),
|
|
52
|
+
phaseEnded: o.phaseEnded ?? (() => false),
|
|
46
53
|
});
|
|
47
54
|
|
|
48
55
|
const sink = () => {
|
|
@@ -310,6 +317,62 @@ suite('agentEnd: kept while the agent is active, collapsed once its activation e
|
|
|
310
317
|
assert.is(events[0].trigger, 'agentEnd');
|
|
311
318
|
});
|
|
312
319
|
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// phaseEnd
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
suite('phaseEnd: kept within the phase, collapsed once the app advances the epoch', () => {
|
|
325
|
+
// Registered in phase epoch 1.
|
|
326
|
+
const history: ChatMessage[] = [asstCall('p1', 'load_entity'), toolMsg('p1', big())];
|
|
327
|
+
const policies = new Map<string, RegisteredCondensePolicy>([
|
|
328
|
+
['p1', reg({ on: { kind: 'phaseEnd' }, response: 'pointer' }, { phaseEpoch: 1 })],
|
|
329
|
+
]);
|
|
330
|
+
const { events, on } = sink();
|
|
331
|
+
|
|
332
|
+
// Still in phase 1 (no endPhase yet) — full.
|
|
333
|
+
const sameP = applyCondensation(history, policies, ctx({ phaseEnded: (e) => e < 1 }), on);
|
|
334
|
+
assert.is(sameP[1].toolResult!.content, big(), 'full while the phase is current');
|
|
335
|
+
assert.is(events.length, 0);
|
|
336
|
+
|
|
337
|
+
// endPhase() advanced the current epoch to 2 → the phase-1 payload collapses.
|
|
338
|
+
const ended = applyCondensation(history, policies, ctx({ phaseEnded: (e) => e < 2 }), on);
|
|
339
|
+
assert.match(ended[1].toolResult!.content, /phase ended/);
|
|
340
|
+
assert.match(ended[1].toolResult!.content, /re-call to restore/);
|
|
341
|
+
assert.is(events.length, 1);
|
|
342
|
+
assert.is(events[0].trigger, 'phaseEnd');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
suite('phaseEnd: falls back to agentEnd when no phase boundary is ever declared', () => {
|
|
346
|
+
// Phase epoch never advances (phaseEnded always false), but the activation ends.
|
|
347
|
+
const history: ChatMessage[] = [asstCall('p1', 'load_entity'), toolMsg('p1', big())];
|
|
348
|
+
const policies = new Map<string, RegisteredCondensePolicy>([
|
|
349
|
+
[
|
|
350
|
+
'p1',
|
|
351
|
+
reg({ on: { kind: 'phaseEnd' }, response: 'pointer' }, { activation: 1, phaseEpoch: 1 }),
|
|
352
|
+
],
|
|
353
|
+
]);
|
|
354
|
+
const { events, on } = sink();
|
|
355
|
+
|
|
356
|
+
const active = applyCondensation(
|
|
357
|
+
history,
|
|
358
|
+
policies,
|
|
359
|
+
ctx({ phaseEnded: () => false, activationEnded: () => false }),
|
|
360
|
+
on,
|
|
361
|
+
);
|
|
362
|
+
assert.is(active[1].toolResult!.content, big(), 'full while both phase and agent are live');
|
|
363
|
+
assert.is(events.length, 0);
|
|
364
|
+
|
|
365
|
+
const disposed = applyCondensation(
|
|
366
|
+
history,
|
|
367
|
+
policies,
|
|
368
|
+
ctx({ phaseEnded: () => false, activationEnded: (a) => a === 1 }),
|
|
369
|
+
on,
|
|
370
|
+
);
|
|
371
|
+
assert.match(disposed[1].toolResult!.content, /phase ended/, 'agentEnd backstop collapses it');
|
|
372
|
+
assert.is(events.length, 1);
|
|
373
|
+
assert.is(events[0].trigger, 'phaseEnd');
|
|
374
|
+
});
|
|
375
|
+
|
|
313
376
|
// ---------------------------------------------------------------------------
|
|
314
377
|
// multi-trigger (OR / first-wins)
|
|
315
378
|
// ---------------------------------------------------------------------------
|
|
@@ -50,6 +50,8 @@ export interface RegisteredCondensePolicy {
|
|
|
50
50
|
turn: number;
|
|
51
51
|
/** Agent-activation sequence when the call was made — the `agentEnd` clock. */
|
|
52
52
|
activation: number;
|
|
53
|
+
/** App-controlled phase epoch when the call was made — the `phaseEnd` clock. */
|
|
54
|
+
phaseEpoch: number;
|
|
53
55
|
/** `context.condensed` already emitted for the args payload (fire-once). */
|
|
54
56
|
reportedArgs?: boolean;
|
|
55
57
|
/** `context.condensed` already emitted for the response payload (fire-once). */
|
|
@@ -68,6 +70,11 @@ export interface CondenseContext {
|
|
|
68
70
|
turn: number;
|
|
69
71
|
/** Whether the agent activation that made a call has since ended — the `agentEnd` clock. */
|
|
70
72
|
activationEnded: (activation: number) => boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Whether a later phase epoch is current than the one a call was made in — the
|
|
75
|
+
* `phaseEnd` clock. True once `endPhase()` has been called since the call.
|
|
76
|
+
*/
|
|
77
|
+
phaseEnded: (phaseEpoch: number) => boolean;
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
/**
|
|
@@ -99,6 +106,8 @@ function triggerReason(trigger: CondenseTrigger): string {
|
|
|
99
106
|
return 'turn ended';
|
|
100
107
|
case 'agentEnd':
|
|
101
108
|
return 'agent finished';
|
|
109
|
+
case 'phaseEnd':
|
|
110
|
+
return 'phase ended';
|
|
102
111
|
default:
|
|
103
112
|
return 'superseded';
|
|
104
113
|
}
|
|
@@ -124,7 +133,7 @@ function triggerLabel(trigger: CondenseTrigger): string {
|
|
|
124
133
|
case 'superseded':
|
|
125
134
|
return `superseded:${trigger.by}`;
|
|
126
135
|
default:
|
|
127
|
-
return trigger.kind; // 'turnEnd' | 'agentEnd'
|
|
136
|
+
return trigger.kind; // 'turnEnd' | 'agentEnd' | 'phaseEnd'
|
|
128
137
|
}
|
|
129
138
|
}
|
|
130
139
|
|
|
@@ -156,6 +165,9 @@ function estimateTokensSaved(origLen: number, stubLen: number): number {
|
|
|
156
165
|
* - `agentEnd` — collapses once the agent activation that made the call has ended
|
|
157
166
|
* (a swap to another agent, or `releaseAgent`/`completeSubAgent`): kept across
|
|
158
167
|
* all of the agent's turns, dropped when its flow finishes.
|
|
168
|
+
* - `phaseEnd` — collapses once the app advances its phase epoch via `endPhase()`
|
|
169
|
+
* after the call (a sub-`agentEnd` boundary the author declares), OR at
|
|
170
|
+
* `agentEnd` as a backstop if no boundary is ever declared.
|
|
159
171
|
*
|
|
160
172
|
* The tool-call/result envelope is never removed (providers reject orphaned
|
|
161
173
|
* calls/results) — only the args object or the result content is replaced.
|
|
@@ -210,6 +222,11 @@ export function applyCondensation(
|
|
|
210
222
|
return ctx.turn > entry.turn;
|
|
211
223
|
case 'agentEnd':
|
|
212
224
|
return ctx.activationEnded(entry.activation);
|
|
225
|
+
// Fires when the app advanced the phase epoch since the call — OR, as a
|
|
226
|
+
// backstop, when the whole activation has ended, so a phase boundary that
|
|
227
|
+
// is never declared still collapses at `agentEnd` rather than never.
|
|
228
|
+
case 'phaseEnd':
|
|
229
|
+
return ctx.phaseEnded(entry.phaseEpoch) || ctx.activationEnded(entry.activation);
|
|
213
230
|
default:
|
|
214
231
|
return latestByKey.get(trig.by) !== toolCallId; // superseded by a newer call
|
|
215
232
|
}
|
|
@@ -7,7 +7,8 @@ import type { AgentConfig } from '../config/config';
|
|
|
7
7
|
* 1. **Directly function-valued** — the lifecycle/dispatch hooks (`onActivate`,
|
|
8
8
|
* `onDeactivate`, `getDebugSnapshot`, `onUnresolvedTool`) and the function
|
|
9
9
|
* form of the per-turn resolvers (`systemPrompt`, `toolDefinitions`,
|
|
10
|
-
* `displayName`, `provider`, `temperature`, `toolChoice`, `
|
|
10
|
+
* `displayName`, `provider`, `temperature`, `toolChoice`, `cachePolicy`,
|
|
11
|
+
* `toolHandlers`).
|
|
11
12
|
* 2. **Object "handler bags" whose *values* are functions** — `toolHandlers` in
|
|
12
13
|
* its object form is `{ name: handler }`, so `typeof` is `'object'`, not
|
|
13
14
|
* `'function'`. A by-value check on the field alone misses it, leaking a live
|