theorum 0.1.10 → 0.1.13

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.
Files changed (40) hide show
  1. package/README.md +25 -2
  2. package/docs/COMPACTION.md +227 -0
  3. package/docs/SECRETS.md +6 -1
  4. package/docs/STOP.md +85 -0
  5. package/esm/mod.d.ts +6 -2
  6. package/esm/mod.js +3 -1
  7. package/esm/src/cli/commands/bench.js +2 -4
  8. package/esm/src/cli/commands/fuzz-guardrails.js +195 -48
  9. package/esm/src/guardrails/injection.js +13 -8
  10. package/esm/src/guardrails/normalize.js +65 -38
  11. package/esm/src/guardrails/sensitive.js +1 -1
  12. package/esm/src/kernel/engine/compaction.d.ts +69 -0
  13. package/esm/src/kernel/engine/compaction.js +141 -0
  14. package/esm/src/kernel/engine/delta.js +30 -7
  15. package/esm/src/kernel/engine/history-tokens.d.ts +43 -0
  16. package/esm/src/kernel/engine/history-tokens.js +100 -0
  17. package/esm/src/kernel/engine/runner/mod.js +164 -62
  18. package/esm/src/kernel/engine/runner/state.d.ts +3 -1
  19. package/esm/src/kernel/engine/runner/steps.js +3 -0
  20. package/esm/src/kernel/mod.d.ts +4 -0
  21. package/esm/src/kernel/mod.js +2 -0
  22. package/esm/src/kernel/registry/profiles.js +37 -0
  23. package/esm/src/kernel/stop.d.ts +75 -0
  24. package/esm/src/kernel/stop.js +120 -0
  25. package/esm/src/kernel/types.d.ts +117 -1
  26. package/esm/src/providers/create-provider.d.ts +7 -0
  27. package/esm/src/providers/create-provider.js +24 -4
  28. package/esm/src/providers/expose-for-tests.js +5 -1
  29. package/esm/src/providers/local.d.ts +29 -0
  30. package/esm/src/providers/local.js +259 -0
  31. package/esm/src/providers/mod.d.ts +2 -0
  32. package/esm/src/providers/mod.js +1 -0
  33. package/esm/src/providers/openrouter.js +32 -13
  34. package/esm/src/providers/provider.js +1 -1
  35. package/esm/src/providers/speech.js +1 -1
  36. package/esm/src/streaming/mod.d.ts +3 -1
  37. package/esm/src/streaming/mod.js +2 -1
  38. package/package.json +8 -3
  39. package/docs/AGENT_PROFILE_CONTRACT.md +0 -189
  40. package/docs/CLI_SPEC.md +0 -183
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Compaction helpers: history split + threshold metering.
3
+ *
4
+ * Pure and stateless. History lives on the request; the kernel does not store
5
+ * cross-turn session state.
6
+ *
7
+ * @module
8
+ */
9
+ import { estimateHistoryTokens } from './history-tokens.js';
10
+ export { estimateHistoryTokens, HISTORY_MEDIA_TOKENS, HISTORY_TEXT_ENCODING, } from './history-tokens.js';
11
+ /** Effective meter; defaults to `'history'`. */
12
+ export function compactionMeter(spec) {
13
+ return spec.meter ?? 'history';
14
+ }
15
+ /**
16
+ * Find exchange boundaries in a history array.
17
+ *
18
+ * An exchange starts at each `user` message and includes all subsequent
19
+ * messages until the next `user` message. System messages before the first
20
+ * user message are not part of any exchange.
21
+ *
22
+ * Returns the indices of each `user` message that starts an exchange.
23
+ */
24
+ function findExchangeBoundaries(history) {
25
+ const boundaries = [];
26
+ for (let i = 0; i < history.length; i++) {
27
+ if (history[i].role === 'user') {
28
+ boundaries.push(i);
29
+ }
30
+ }
31
+ return boundaries;
32
+ }
33
+ /**
34
+ * History token count for `meter: 'history'`.
35
+ *
36
+ * Prefers host-supplied `input.historyTokens`. Otherwise estimates from
37
+ * `input.history` (tiktoken `o200k_base` + media stubs). Empty/missing → 0.
38
+ * The BPE import runs only when an estimate needs text encoding.
39
+ */
40
+ export async function resolveHistoryTokens(input) {
41
+ if (input?.historyTokens != null) {
42
+ return input.historyTokens;
43
+ }
44
+ return await estimateHistoryTokens(input?.history ?? []);
45
+ }
46
+ /**
47
+ * Resolve the token count used for the compaction threshold.
48
+ *
49
+ * - `meter: 'history'` (default) — `historyTokens` or estimate of `history`.
50
+ * - `meter: 'input'` — prefer `promptTokens` (this turn's provider
51
+ * `tokens.input`, for `timing: 'after'`), else host `input.inputTokens`
52
+ * (previous turn, for `timing: 'before'`). Missing/non-positive → undefined
53
+ * (do not fire).
54
+ *
55
+ * `meter: 'input'` never loads the history tokenizer.
56
+ */
57
+ export async function resolveCompactionTokens(args) {
58
+ const meter = compactionMeter(args.spec);
59
+ if (meter === 'history') {
60
+ return { meter, tokens: await resolveHistoryTokens(args.input) };
61
+ }
62
+ const fromPrompt = args.promptTokens != null && args.promptTokens > 0 ? args.promptTokens : undefined;
63
+ const fromHost = args.input?.inputTokens != null && args.input.inputTokens > 0
64
+ ? args.input.inputTokens
65
+ : undefined;
66
+ const tokens = fromPrompt ?? fromHost;
67
+ if (tokens == null)
68
+ return undefined;
69
+ return { meter, tokens };
70
+ }
71
+ /** Whether compaction should fire for a resolved token count (token-threshold only). */
72
+ export function compactionNeeded(tokens, spec) {
73
+ return tokens > spec.compactAt * spec.maxTokens;
74
+ }
75
+ /**
76
+ * Whether compaction should fire, respecting a custom trigger when provided.
77
+ *
78
+ * When `spec.trigger` is set, it is called with full context and its result
79
+ * is returned directly. Otherwise falls back to `compactionNeeded`.
80
+ */
81
+ export async function shouldCompact(resolved, spec) {
82
+ if (spec.trigger) {
83
+ const ctx = {
84
+ tokens: resolved.tokens,
85
+ maxTokens: spec.maxTokens,
86
+ compactAt: spec.compactAt,
87
+ meter: resolved.meter,
88
+ };
89
+ return await spec.trigger(ctx);
90
+ }
91
+ return compactionNeeded(resolved.tokens, spec);
92
+ }
93
+ /**
94
+ * Split history into compactable and retained segments.
95
+ *
96
+ * `previousExchanges` semantics:
97
+ * - `0` — compact everything, retain nothing.
98
+ * - `≥ 1` (integer) — retain the last N exchanges.
99
+ * - `(0, 1)` — retain exchanges that fit within this fraction of `maxTokens`,
100
+ * walking backwards from the most recent (always uses the history estimator).
101
+ */
102
+ export async function splitForCompaction(history, spec) {
103
+ if (history.length === 0) {
104
+ return { toCompact: [], toRetain: [] };
105
+ }
106
+ if (spec.previousExchanges === 0) {
107
+ return { toCompact: [...history], toRetain: [] };
108
+ }
109
+ const boundaries = findExchangeBoundaries(history);
110
+ if (boundaries.length === 0) {
111
+ return { toCompact: [...history], toRetain: [] };
112
+ }
113
+ let cutIndex;
114
+ if (spec.previousExchanges >= 1) {
115
+ const keep = Math.min(spec.previousExchanges, boundaries.length);
116
+ cutIndex = boundaries[boundaries.length - keep];
117
+ }
118
+ else {
119
+ const budget = spec.previousExchanges * spec.maxTokens;
120
+ let accumulated = 0;
121
+ cutIndex = history.length;
122
+ for (let i = boundaries.length - 1; i >= 0; i--) {
123
+ const exchangeStart = boundaries[i];
124
+ const exchangeEnd = i < boundaries.length - 1 ? boundaries[i + 1] : history.length;
125
+ const exchangeMessages = history.slice(exchangeStart, exchangeEnd);
126
+ const exchangeTokens = await estimateHistoryTokens(exchangeMessages);
127
+ if (accumulated + exchangeTokens > budget) {
128
+ break;
129
+ }
130
+ accumulated += exchangeTokens;
131
+ cutIndex = exchangeStart;
132
+ }
133
+ }
134
+ if (cutIndex <= 0) {
135
+ return { toCompact: [], toRetain: [...history] };
136
+ }
137
+ return {
138
+ toCompact: history.slice(0, cutIndex),
139
+ toRetain: history.slice(cutIndex),
140
+ };
141
+ }
@@ -1,3 +1,4 @@
1
+ import { turnStopFromInteractionStatus } from '../stop.js';
1
2
  import { asRecord } from './record.js';
2
3
  function deltaText(delta) {
3
4
  if (typeof delta.text === 'string') {
@@ -359,25 +360,47 @@ function extractTokenEvent(event, interaction) {
359
360
  }
360
361
  function eventsFromComplete(event, alreadyText) {
361
362
  const interaction = asRecord(event.interaction) ?? event;
362
- const outputText = interaction.output_text;
363
363
  const events = [];
364
+ const outputText = interaction.output_text;
364
365
  if (!alreadyText && typeof outputText === 'string' && outputText) {
365
366
  events.push({ type: 'text', text: outputText });
366
367
  }
367
368
  const media = mediaFromComplete(event);
368
- if (media) {
369
+ if (media)
369
370
  events.push(media);
370
- }
371
371
  const tokenEvent = extractTokenEvent(event, interaction);
372
- if (tokenEvent) {
372
+ if (tokenEvent)
373
373
  events.push(tokenEvent);
374
- }
375
374
  const groundingEvent = groundingFromEvent(event);
376
- if (groundingEvent) {
375
+ if (groundingEvent)
377
376
  events.push(groundingEvent);
378
- }
377
+ const done = doneFromInteractionStatus(interaction, event);
378
+ if (done)
379
+ events.push(done);
379
380
  return events;
380
381
  }
382
+ const TERMINAL_INTERACTION_STATUSES = new Set([
383
+ 'completed',
384
+ 'incomplete',
385
+ 'budget_exceeded',
386
+ 'failed',
387
+ 'cancelled',
388
+ 'requires_action',
389
+ ]);
390
+ function doneFromInteractionStatus(interaction, event) {
391
+ const status = typeof interaction.status === 'string'
392
+ ? interaction.status
393
+ : typeof event.status === 'string'
394
+ ? event.status
395
+ : undefined;
396
+ if (!status || !TERMINAL_INTERACTION_STATUSES.has(status.toLowerCase()))
397
+ return undefined;
398
+ return {
399
+ type: 'done',
400
+ stop: turnStopFromInteractionStatus(status),
401
+ ...(typeof interaction.id === 'string' ? { interactionId: interaction.id } : {}),
402
+ };
403
+ }
381
404
  function tryStructured(text) {
382
405
  try {
383
406
  return { type: 'structured', structured: JSON.parse(text) };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Local estimate of conversational history tokens (compaction `meter: 'history'`).
3
+ *
4
+ * **Text** — tiktoken `o200k_base` via `gpt-tokenizer` (GPT-4o / GPT-5 / o-series
5
+ * default encoding). This is THEORUM's declared local BPE counter. Gemini has
6
+ * no open JS tokenizer; hosts that need Gemini `countTokens` pass
7
+ * `TurnInput.historyTokens`.
8
+ *
9
+ * The BPE ranks are loaded lazily on first text encode — not at module import.
10
+ * Hosts that pass `historyTokens`, use `meter: 'input'`, or never estimate
11
+ * history text never pay for the import.
12
+ *
13
+ * **Media** — not payload bytes. Current-turn files belong on `attachments` /
14
+ * `voice` and are outside this meter. History media parts use published
15
+ * Gemini multimodal *rates as minimum stubs* when dimensions / duration are
16
+ * not on the part:
17
+ * - image / document: one still-image tile (258) — Gemini 2.x small-image /
18
+ * one-page unit. Larger images are 258×tiles; Gemini 3 uses
19
+ * `media_resolution` budgets (often 560–1120), not a flat 258.
20
+ * - audio: 32 tokens (1s @ 32/s). Longer clips scale with seconds.
21
+ * - video: 263 tokens (1s @ 263/s). Longer clips scale with seconds.
22
+ *
23
+ * These stubs are intentional minima for “media is present in history,” not
24
+ * billing-grade multimodal accounting. Prefer `historyTokens` when the host
25
+ * knows better.
26
+ *
27
+ * @module
28
+ */
29
+ import type { TurnHistoryMessage } from '../types.js';
30
+ /** Tiktoken encoding used for history text. */
31
+ export declare const HISTORY_TEXT_ENCODING = "o200k_base";
32
+ /**
33
+ * Minimum media stubs when size/duration are unknown.
34
+ * See module doc — not “every image costs 258 forever.”
35
+ */
36
+ export declare const HISTORY_MEDIA_TOKENS: {
37
+ readonly image: 258;
38
+ readonly document: 258;
39
+ readonly audio: 32;
40
+ readonly video: 263;
41
+ };
42
+ /** BPE-count conversational history (text + media stubs). Lazy-loads o200k. */
43
+ export declare function estimateHistoryTokens(messages: TurnHistoryMessage[]): Promise<number>;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Local estimate of conversational history tokens (compaction `meter: 'history'`).
3
+ *
4
+ * **Text** — tiktoken `o200k_base` via `gpt-tokenizer` (GPT-4o / GPT-5 / o-series
5
+ * default encoding). This is THEORUM's declared local BPE counter. Gemini has
6
+ * no open JS tokenizer; hosts that need Gemini `countTokens` pass
7
+ * `TurnInput.historyTokens`.
8
+ *
9
+ * The BPE ranks are loaded lazily on first text encode — not at module import.
10
+ * Hosts that pass `historyTokens`, use `meter: 'input'`, or never estimate
11
+ * history text never pay for the import.
12
+ *
13
+ * **Media** — not payload bytes. Current-turn files belong on `attachments` /
14
+ * `voice` and are outside this meter. History media parts use published
15
+ * Gemini multimodal *rates as minimum stubs* when dimensions / duration are
16
+ * not on the part:
17
+ * - image / document: one still-image tile (258) — Gemini 2.x small-image /
18
+ * one-page unit. Larger images are 258×tiles; Gemini 3 uses
19
+ * `media_resolution` budgets (often 560–1120), not a flat 258.
20
+ * - audio: 32 tokens (1s @ 32/s). Longer clips scale with seconds.
21
+ * - video: 263 tokens (1s @ 263/s). Longer clips scale with seconds.
22
+ *
23
+ * These stubs are intentional minima for “media is present in history,” not
24
+ * billing-grade multimodal accounting. Prefer `historyTokens` when the host
25
+ * knows better.
26
+ *
27
+ * @module
28
+ */
29
+ /** Tiktoken encoding used for history text. */
30
+ export const HISTORY_TEXT_ENCODING = 'o200k_base';
31
+ /**
32
+ * Minimum media stubs when size/duration are unknown.
33
+ * See module doc — not “every image costs 258 forever.”
34
+ */
35
+ export const HISTORY_MEDIA_TOKENS = {
36
+ image: 258,
37
+ document: 258,
38
+ audio: 32,
39
+ video: 263,
40
+ };
41
+ let encodePromise = null;
42
+ function loadEncode() {
43
+ encodePromise ??= import('gpt-tokenizer/encoding/o200k_base').then((m) => m.encode);
44
+ return encodePromise;
45
+ }
46
+ function mediaTokens(kind) {
47
+ return HISTORY_MEDIA_TOKENS[kind];
48
+ }
49
+ function partNeedsText(part) {
50
+ return part.type === 'text' && Boolean(part.text);
51
+ }
52
+ function messageNeedsText(msg) {
53
+ if (msg.content)
54
+ return true;
55
+ if (msg.parts?.some(partNeedsText))
56
+ return true;
57
+ return Boolean(msg.tool_calls?.some((tc) => tc.function.arguments));
58
+ }
59
+ function needsTextEncode(messages) {
60
+ return messages.some(messageNeedsText);
61
+ }
62
+ function sumMediaStubs(messages) {
63
+ let tokens = 0;
64
+ for (const msg of messages) {
65
+ for (const part of msg.parts ?? []) {
66
+ if (part.type !== 'text')
67
+ tokens += mediaTokens(part.type);
68
+ }
69
+ }
70
+ return tokens;
71
+ }
72
+ async function countMessageTokens(msg, countText) {
73
+ let tokens = 0;
74
+ if (msg.content)
75
+ tokens += await countText(msg.content);
76
+ for (const part of msg.parts ?? []) {
77
+ tokens += part.type === 'text' ? await countText(part.text) : mediaTokens(part.type);
78
+ }
79
+ for (const tc of msg.tool_calls ?? []) {
80
+ tokens += await countText(tc.function.arguments);
81
+ }
82
+ return tokens;
83
+ }
84
+ /** BPE-count conversational history (text + media stubs). Lazy-loads o200k. */
85
+ export async function estimateHistoryTokens(messages) {
86
+ if (!needsTextEncode(messages))
87
+ return sumMediaStubs(messages);
88
+ let encode;
89
+ const countText = async (text) => {
90
+ if (!text)
91
+ return 0;
92
+ encode ??= await loadEncode();
93
+ return encode(text).length;
94
+ };
95
+ let tokens = 0;
96
+ for (const msg of messages) {
97
+ tokens += await countMessageTokens(msg, countText);
98
+ }
99
+ return tokens;
100
+ }
@@ -12,11 +12,87 @@ import { sanitizeTurnRequest } from '../../../guardrails/sanitize.js';
12
12
  import { noopSink, writeTrace } from '../../../observability/trace.js';
13
13
  import { buildRecord } from '../../../observability/trace-record.js';
14
14
  import { pickSystemRole, resolveTurn } from '../../registry/resolve.js';
15
+ import { CONTINUE_INSTRUCTION } from '../../stop.js';
15
16
  import { bindCanary } from '../boundary.js';
17
+ import { resolveCompactionTokens, shouldCompact, splitForCompaction } from '../compaction.js';
16
18
  import { runAttemptsWithValidation } from './gates.js';
17
19
  import { shouldSkipStreamEvent, systemFromProfile } from './stream.js';
18
20
  import { calculateFallbackTokens } from './tokens.js';
19
21
  import { invokeFromUi } from './tools.js';
22
+ function getCompactionSpec(profile, modelId) {
23
+ return profile.model.config[modelId]?.compaction;
24
+ }
25
+ async function runCompactionTurn(toCompact, spec, provider, signal) {
26
+ const compactText = toCompact
27
+ .map((m) => {
28
+ const content = m.content ?? m.parts?.map((p) => ('text' in p ? p.text : '')).join('') ?? '';
29
+ return `[${m.role}]: ${content}`;
30
+ })
31
+ .join('\n');
32
+ const events = [];
33
+ for await (const event of runTurn({
34
+ profile: spec.profile,
35
+ input: { text: compactText },
36
+ signal,
37
+ metadata: { _compacting: true },
38
+ }, provider)) {
39
+ events.push(event);
40
+ }
41
+ const structured = events.find((e) => e.type === 'structured')?.structured;
42
+ const text = structured
43
+ ? JSON.stringify(structured)
44
+ : events
45
+ .filter((e) => e.type === 'text')
46
+ .map((e) => e.text ?? '')
47
+ .join('');
48
+ return {
49
+ role: 'assistant',
50
+ content: text,
51
+ metadata: { compactionSummary: true },
52
+ };
53
+ }
54
+ function lastTokensFromEvents(events) {
55
+ for (let i = events.length - 1; i >= 0; i--) {
56
+ const input = events[i]?.tokens?.input;
57
+ if (input)
58
+ return input;
59
+ }
60
+ return 0;
61
+ }
62
+ async function compactHistoryBeforeTurn(args) {
63
+ const decision = await resolveCompactionTokens({
64
+ spec: args.spec,
65
+ input: args.input,
66
+ });
67
+ if (!(decision && (await shouldCompact(decision, args.spec))))
68
+ return args.history;
69
+ const { toCompact, toRetain } = await splitForCompaction(args.history, args.spec);
70
+ if (toCompact.length === 0)
71
+ return args.history;
72
+ const summaryMessage = await runCompactionTurn(toCompact, args.spec, args.compactionProvider ?? args.provider, args.signal);
73
+ return [summaryMessage, ...toRetain];
74
+ }
75
+ async function attachAfterCompaction(event, args) {
76
+ if (args.history.length === 0)
77
+ return event;
78
+ const promptTokens = lastTokensFromEvents(args.seen);
79
+ const decision = await resolveCompactionTokens({
80
+ spec: args.spec,
81
+ input: args.input,
82
+ promptTokens,
83
+ });
84
+ if (!(decision && (await shouldCompact(decision, args.spec))))
85
+ return event;
86
+ const signal = {
87
+ needed: true,
88
+ meter: decision.meter,
89
+ tokens: decision.tokens,
90
+ history: args.history,
91
+ };
92
+ if (promptTokens > 0)
93
+ signal.promptTokens = promptTokens;
94
+ return { ...event, compaction: signal };
95
+ }
20
96
  async function* emitTurn(args) {
21
97
  const { safe, profile, generation, system, provider, gemini } = args;
22
98
  if (safe.toolInvoke) {
@@ -34,75 +110,101 @@ async function* emitTurn(args) {
34
110
  if (!state.sawTokensEvent) {
35
111
  yield* calculateFallbackTokens(safe, system, state.allEmittedEvents);
36
112
  }
37
- yield { type: 'done' };
113
+ yield {
114
+ type: 'done',
115
+ stop: state.lastStop ?? { kind: 'completed' },
116
+ };
117
+ }
118
+ async function flushTurnTrace(sink, ctx) {
119
+ await writeTrace(sink, buildRecord({
120
+ req: ctx.req,
121
+ events: ctx.seen,
122
+ started: ctx.started,
123
+ model: ctx.model,
124
+ bucket: ctx.bucket,
125
+ thrown: ctx.thrown,
126
+ gemini: ctx.gemini,
127
+ canary: ctx.canary,
128
+ system: ctx.system,
129
+ generation: ctx.generation,
130
+ sanitizedReq: ctx.safe,
131
+ }));
38
132
  }
39
133
  /** Execute one host turn against a provider adapter. */
40
134
  async function* runTurn(req, provider, sink = noopSink()) {
41
- const started = Date.now();
42
- const seen = [];
43
- const gemini = [];
44
- let model;
45
- let bucket;
46
- let canary = '';
47
- let system;
48
- let generation;
49
- let safe;
135
+ const ctx = {
136
+ req,
137
+ seen: [],
138
+ started: Date.now(),
139
+ canary: '',
140
+ gemini: [],
141
+ };
50
142
  try {
51
- safe = sanitizeTurnRequest(req);
52
- throwIfAborted(safe.signal);
53
- const { profile, generation: gen } = resolveTurn(safe);
54
- generation = gen;
55
- const { model: resolvedModel, geminiBucket, canary: turnCanary } = gen;
56
- model = resolvedModel;
57
- bucket = geminiBucket;
58
- canary = turnCanary;
59
- const role = pickSystemRole(profile, safe.input?.role);
60
- const profileSys = systemFromProfile(profile, role);
61
- const combinedSys = [profileSys, safe.system].filter(Boolean).join('\n\n');
62
- const bound = bindCanary(combinedSys, turnCanary);
63
- system = bound;
64
- for await (const event of emitTurn({
65
- safe,
66
- profile,
67
- generation: gen,
68
- system: bound,
69
- provider,
70
- gemini,
71
- })) {
72
- seen.push(event);
73
- if (shouldSkipStreamEvent(event, profile)) {
74
- continue;
75
- }
76
- yield event;
77
- }
143
+ yield* runTurnBody(ctx, provider);
78
144
  }
79
145
  catch (err) {
80
- await writeTrace(sink, buildRecord({
81
- req,
82
- events: seen,
83
- started,
84
- model,
85
- bucket,
86
- thrown: err,
87
- gemini,
88
- canary,
89
- system,
90
- generation,
91
- sanitizedReq: safe,
92
- }));
146
+ await flushTurnTrace(sink, { ...ctx, thrown: err });
93
147
  throw err;
94
148
  }
95
- await writeTrace(sink, buildRecord({
96
- req,
97
- events: seen,
98
- started,
99
- model,
100
- bucket,
101
- gemini,
102
- canary,
103
- system,
104
- generation,
105
- sanitizedReq: safe,
106
- }));
149
+ await flushTurnTrace(sink, ctx);
150
+ }
151
+ async function* runTurnBody(ctx, provider) {
152
+ ctx.safe = sanitizeTurnRequest(ctx.req);
153
+ throwIfAborted(ctx.safe.signal);
154
+ const { profile, generation: gen } = resolveTurn(ctx.safe);
155
+ ctx.generation = gen;
156
+ ctx.model = gen.model;
157
+ ctx.bucket = gen.geminiBucket;
158
+ ctx.canary = gen.canary;
159
+ const isCompacting = ctx.req.metadata?._compacting === true;
160
+ const compactionSpec = isCompacting ? undefined : getCompactionSpec(profile, gen.model);
161
+ await maybeCompactBefore(ctx, gen, compactionSpec, provider);
162
+ const role = pickSystemRole(profile, ctx.safe.input?.role);
163
+ const continueSys = ctx.safe.continueFrom ? CONTINUE_INSTRUCTION : '';
164
+ const combinedSys = [systemFromProfile(profile, role), ctx.safe.system, continueSys]
165
+ .filter(Boolean)
166
+ .join('\n\n');
167
+ ctx.system = bindCanary(combinedSys, ctx.canary);
168
+ yield* streamTurnEvents(ctx, profile, gen, provider, compactionSpec, isCompacting);
169
+ }
170
+ async function maybeCompactBefore(ctx, gen, compactionSpec, provider) {
171
+ if (!(compactionSpec?.timing === 'before' && gen.history?.length && ctx.safe))
172
+ return;
173
+ gen.history = await compactHistoryBeforeTurn({
174
+ spec: compactionSpec,
175
+ history: gen.history,
176
+ input: ctx.safe.input,
177
+ provider,
178
+ compactionProvider: ctx.req.compactionProvider,
179
+ signal: ctx.safe.signal,
180
+ });
181
+ }
182
+ async function* streamTurnEvents(ctx, profile, gen, provider, compactionSpec, isCompacting) {
183
+ if (!ctx.safe || ctx.system === undefined)
184
+ return;
185
+ for await (const event of emitTurn({
186
+ safe: ctx.safe,
187
+ profile,
188
+ generation: gen,
189
+ system: ctx.system,
190
+ provider,
191
+ gemini: ctx.gemini,
192
+ })) {
193
+ const out = await maybeAttachAfter(event, ctx, gen, compactionSpec, isCompacting);
194
+ ctx.seen.push(out);
195
+ if (!shouldSkipStreamEvent(out, profile))
196
+ yield out;
197
+ }
198
+ }
199
+ async function maybeAttachAfter(event, ctx, gen, compactionSpec, isCompacting) {
200
+ if (!(event.type === 'done' && compactionSpec?.timing === 'after' && !isCompacting && ctx.safe)) {
201
+ return event;
202
+ }
203
+ return await attachAfterCompaction(event, {
204
+ spec: compactionSpec,
205
+ history: gen.history ?? [],
206
+ input: ctx.safe.input,
207
+ seen: ctx.seen,
208
+ });
107
209
  }
108
210
  export { runTurn };
@@ -1,10 +1,12 @@
1
- import type { ResolvedGeneration, TurnEvent, TurnHistoryMessage, TurnRequest } from '../../types.js';
1
+ import type { ResolvedGeneration, TurnEvent, TurnHistoryMessage, TurnRequest, TurnStop } from '../../types.js';
2
2
  interface StepExecutionState {
3
3
  currentHistory: TurnHistoryMessage[];
4
4
  stepCount: number;
5
5
  sawTokensEvent: boolean;
6
6
  allEmittedEvents: TurnEvent[];
7
7
  attemptEvents: TurnEvent[];
8
+ /** Last provider stop from a discarded provider `done` event. */
9
+ lastStop?: TurnStop;
8
10
  }
9
11
  interface AttemptFlowState {
10
12
  currentAttempt: number;
@@ -28,6 +28,9 @@ async function* executeAutonomousStep(args, state, buffer = {
28
28
  latestStructured = event.structured;
29
29
  }
30
30
  if (event.type === 'done') {
31
+ if (event.stop) {
32
+ state.lastStop = event.stop;
33
+ }
31
34
  continue;
32
35
  }
33
36
  if (event.type === 'tool' && event.tool) {
@@ -8,6 +8,8 @@
8
8
  * @module
9
9
  */
10
10
  import "../../_dnt.polyfills.js";
11
+ export type { CompactionSplit, CompactionTokens } from './engine/compaction.js';
12
+ export { compactionMeter, compactionNeeded, estimateHistoryTokens, HISTORY_MEDIA_TOKENS, HISTORY_TEXT_ENCODING, resolveCompactionTokens, resolveHistoryTokens, shouldCompact, splitForCompaction, } from './engine/compaction.js';
11
13
  export { runTurn } from './engine/runner.js';
12
14
  export { CATALOG, clampThinkingLevel, clampThinkingLevelForApiId, geminiKindForMime, getTool, listBuiltinIds, mimeAllowed, mimeEssence, modelEntryByApiId, registerTools, requireModelSpec, resetTools, } from './registry/catalog.js';
13
15
  export type { ProfileDefinition } from './registry/profiles.js';
@@ -15,4 +17,6 @@ export { clearProfiles, defineProfile, getProfile, hasProfile, listProfiles, reg
15
17
  export { projectProfile, resolveTurn } from './registry/resolve.js';
16
18
  export { getStructured, registerStructured } from './registry/schemas.js';
17
19
  export { executeTool } from './registry/tools.js';
20
+ export type { ProfileResumeSpec, TurnContinueFrom, TurnStop, TurnStopKind } from './stop.js';
21
+ export { AUTO_CONTINUE_DELAY_MS, CONTINUE_INSTRUCTION, DEFAULT_AUTO_CONTINUE, GenerationStopError, isGenerationStopError, isResumeableStop, isUserCancelledStop, shouldAutoContinue, turnStopFromClientStreamEnd, turnStopFromInteractionStatus, turnStopFromOpenRouter, } from './stop.js';
18
22
  export type * from './types.js';
@@ -8,9 +8,11 @@
8
8
  * @module
9
9
  */
10
10
  import "../../_dnt.polyfills.js";
11
+ export { compactionMeter, compactionNeeded, estimateHistoryTokens, HISTORY_MEDIA_TOKENS, HISTORY_TEXT_ENCODING, resolveCompactionTokens, resolveHistoryTokens, shouldCompact, splitForCompaction, } from './engine/compaction.js';
11
12
  export { runTurn } from './engine/runner.js';
12
13
  export { CATALOG, clampThinkingLevel, clampThinkingLevelForApiId, geminiKindForMime, getTool, listBuiltinIds, mimeAllowed, mimeEssence, modelEntryByApiId, registerTools, requireModelSpec, resetTools, } from './registry/catalog.js';
13
14
  export { clearProfiles, defineProfile, getProfile, hasProfile, listProfiles, registerProfile, registerProfiles, } from './registry/profiles.js';
14
15
  export { projectProfile, resolveTurn } from './registry/resolve.js';
15
16
  export { getStructured, registerStructured } from './registry/schemas.js';
16
17
  export { executeTool } from './registry/tools.js';
18
+ export { AUTO_CONTINUE_DELAY_MS, CONTINUE_INSTRUCTION, DEFAULT_AUTO_CONTINUE, GenerationStopError, isGenerationStopError, isResumeableStop, isUserCancelledStop, shouldAutoContinue, turnStopFromClientStreamEnd, turnStopFromInteractionStatus, turnStopFromOpenRouter, } from './stop.js';