tinker-agent 1.9.0 → 1.10.1

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 (47) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/README.md +64 -6
  3. package/package.json +1 -1
  4. package/src/agent/loop.ts +13 -0
  5. package/src/agent/runtime-session.ts +165 -0
  6. package/src/agent/session-ledger.ts +20 -3
  7. package/src/cli/config.ts +11 -2
  8. package/src/cli/model-profiles.ts +58 -0
  9. package/src/cli/public-config-contract.ts +73 -7
  10. package/src/cli/run-runner.ts +4 -1
  11. package/src/cli/runner-dependencies.ts +28 -4
  12. package/src/cli/tui-memory.ts +4 -0
  13. package/src/cli/tui-runner.tsx +8 -1
  14. package/src/context/context-automation-policy.ts +22 -21
  15. package/src/context/context-manager.ts +91 -15
  16. package/src/context/context-policy.ts +0 -2
  17. package/src/context/context-swap-renderer.ts +1 -1
  18. package/src/context/prefix-retirement-planner.ts +58 -8
  19. package/src/context/recall-retirement-contract.ts +5 -4
  20. package/src/context/swap-planner.ts +33 -27
  21. package/src/model/fake-model-client.ts +26 -16
  22. package/src/model/model-api.ts +12 -0
  23. package/src/model/model-client.ts +9 -1
  24. package/src/model/moonshot-input-token-estimator.ts +5 -1
  25. package/src/model/openai-chat-mapping.ts +2 -24
  26. package/src/model/openai-chat-model-client.ts +18 -294
  27. package/src/model/openai-image-mapping.ts +20 -0
  28. package/src/model/openai-model-utils.ts +304 -0
  29. package/src/model/openai-responses-mapping.ts +532 -0
  30. package/src/model/openai-responses-model-client.ts +295 -0
  31. package/src/model/openai-responses-stream.ts +96 -0
  32. package/src/model/openai-responses-token-estimator.ts +155 -0
  33. package/src/model/reasoning-effort.ts +60 -0
  34. package/src/session/session-catalog.ts +2 -2
  35. package/src/session/session-history-reader.ts +6 -1
  36. package/src/session/session-schema.ts +268 -4
  37. package/src/session/session-store.ts +105 -26
  38. package/src/skills/skill-context.ts +2 -2
  39. package/src/tools/bounded-output-preview.ts +276 -0
  40. package/src/tools/recall.ts +67 -36
  41. package/src/tools/registry.ts +7 -2
  42. package/src/tools/task-output-snapshot.ts +6 -22
  43. package/src/tools/task-output.ts +23 -27
  44. package/src/tui/app.tsx +82 -5
  45. package/src/tui/components/prompt-input.tsx +9 -1
  46. package/src/tui/slash-commands.ts +20 -0
  47. package/src/tui/tui-session-controller.ts +7 -0
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { rgPath } from "@vscode/ripgrep";
3
+ import { parseModelApi, type ModelApi } from "../model/model-api";
3
4
 
4
5
  export type PublicConfigValueKind = "non-empty-string" | "positive-integer" | "boolean";
5
6
 
@@ -39,6 +40,16 @@ export const PUBLIC_CONFIG_FIELDS = Object.freeze([
39
40
  section: "model",
40
41
  description: "Model name used when model profiles are not configured.",
41
42
  }),
43
+ publicField({
44
+ name: "TINKER_API",
45
+ valueKind: "non-empty-string",
46
+ requiredIn: "never",
47
+ appliesIn: "env-mode",
48
+ defaultValue: "chat-completions",
49
+ secret: false,
50
+ section: "model",
51
+ description: 'Model API adapter: "chat-completions" or "responses".',
52
+ }),
42
53
  publicField({
43
54
  name: "TINKER_BASE_URL",
44
55
  valueKind: "non-empty-string",
@@ -46,7 +57,8 @@ export const PUBLIC_CONFIG_FIELDS = Object.freeze([
46
57
  appliesIn: "env-mode",
47
58
  secret: false,
48
59
  section: "model",
49
- description: "OpenAI-compatible Chat Completions API base URL.",
60
+ description:
61
+ "OpenAI-compatible API root URL; do not append /chat/completions or /responses.",
50
62
  }),
51
63
  publicField({
52
64
  name: "TINKER_API_KEY",
@@ -84,7 +96,8 @@ export const PUBLIC_CONFIG_FIELDS = Object.freeze([
84
96
  defaultValue: false,
85
97
  secret: false,
86
98
  section: "model",
87
- description: "Include provider reasoning content in the model response mapping.",
99
+ description:
100
+ "Replay provider reasoning_content in Chat Completions history; ignored by Responses.",
88
101
  }),
89
102
  publicField({
90
103
  name: "TINKER_STREAM",
@@ -94,7 +107,7 @@ export const PUBLIC_CONFIG_FIELDS = Object.freeze([
94
107
  defaultValue: true,
95
108
  secret: false,
96
109
  section: "model",
97
- description: "Use streaming Chat Completions transport.",
110
+ description: "Use streaming transport for the selected model API.",
98
111
  }),
99
112
  publicField({
100
113
  name: "TINKER_WEBFETCH_REFINE_MODEL",
@@ -229,7 +242,11 @@ export const PUBLIC_CONFIG_FIELDS = Object.freeze([
229
242
 
230
243
  export type ModelProfileField = {
231
244
  readonly name: string;
232
- readonly valueKind: PublicConfigValueKind | "input-modalities" | "token-estimator";
245
+ readonly valueKind:
246
+ | PublicConfigValueKind
247
+ | "input-modalities"
248
+ | "reasoning"
249
+ | "token-estimator";
233
250
  readonly required: boolean;
234
251
  readonly defaultValue?: string | number | boolean | readonly string[];
235
252
  readonly secret: boolean;
@@ -248,12 +265,21 @@ export const MODEL_PROFILE_FIELDS = Object.freeze([
248
265
  secret: false,
249
266
  description: "Provider model name.",
250
267
  }),
268
+ profileField({
269
+ name: "api",
270
+ valueKind: "non-empty-string",
271
+ required: false,
272
+ defaultValue: "chat-completions",
273
+ secret: false,
274
+ description: 'Model API adapter: "chat-completions" or "responses".',
275
+ }),
251
276
  profileField({
252
277
  name: "apiBase",
253
278
  valueKind: "non-empty-string",
254
279
  required: true,
255
280
  secret: false,
256
- description: "OpenAI-compatible API base URL.",
281
+ description:
282
+ "OpenAI-compatible API root URL; do not append /chat/completions or /responses.",
257
283
  }),
258
284
  profileField({
259
285
  name: "apiKey",
@@ -277,13 +303,22 @@ export const MODEL_PROFILE_FIELDS = Object.freeze([
277
303
  description:
278
304
  "Maximum output-token count supported by the model; must not exceed contextWindowTokens.",
279
305
  }),
306
+ profileField({
307
+ name: "reasoning",
308
+ valueKind: "reasoning",
309
+ required: false,
310
+ secret: false,
311
+ description:
312
+ "Provider-specific reasoning efforts and the default for each new session runtime.",
313
+ }),
280
314
  profileField({
281
315
  name: "includeReasoningContent",
282
316
  valueKind: "boolean",
283
317
  required: false,
284
318
  defaultValue: false,
285
319
  secret: false,
286
- description: "Include provider reasoning content in response mapping.",
320
+ description:
321
+ "Replay provider reasoning_content in Chat Completions history; ignored by Responses.",
287
322
  }),
288
323
  profileField({
289
324
  name: "stream",
@@ -291,7 +326,7 @@ export const MODEL_PROFILE_FIELDS = Object.freeze([
291
326
  required: false,
292
327
  defaultValue: true,
293
328
  secret: false,
294
- description: "Use streaming Chat Completions transport.",
329
+ description: "Use streaming transport for the selected model API.",
295
330
  }),
296
331
  profileField({
297
332
  name: "inputModalities",
@@ -311,6 +346,35 @@ export const MODEL_PROFILE_FIELDS = Object.freeze([
311
346
  }),
312
347
  ]);
313
348
 
349
+ export type ModelReasoningField = {
350
+ readonly name: string;
351
+ readonly valueKind: "non-empty-string" | "non-empty-string-array";
352
+ readonly required: true;
353
+ readonly secret: false;
354
+ readonly description: string;
355
+ };
356
+
357
+ function reasoningField<const T extends ModelReasoningField>(field: T): Readonly<T> {
358
+ return Object.freeze(field);
359
+ }
360
+
361
+ export const MODEL_REASONING_FIELDS = Object.freeze([
362
+ reasoningField({
363
+ name: "supportedEfforts",
364
+ valueKind: "non-empty-string-array",
365
+ required: true,
366
+ secret: false,
367
+ description: "Provider-supported effort values exposed by the /reasoning command.",
368
+ }),
369
+ reasoningField({
370
+ name: "defaultEffort",
371
+ valueKind: "non-empty-string",
372
+ required: true,
373
+ secret: false,
374
+ description: "Effort used whenever a session runtime is created or reopened.",
375
+ }),
376
+ ]);
377
+
314
378
  export type ModelTokenEstimatorField = {
315
379
  readonly name: string;
316
380
  readonly valueKind: PublicConfigValueKind | "literal-string" | "literal-number";
@@ -502,6 +566,7 @@ export type ParsedPublicEnvironment =
502
566
  | (ParsedCommonEnvironment & {
503
567
  readonly mode: "env";
504
568
  readonly modelName: string;
569
+ readonly api: ModelApi;
505
570
  readonly apiBase: string;
506
571
  readonly apiKey: string;
507
572
  readonly contextWindowTokens: number;
@@ -615,6 +680,7 @@ export function parsePublicEnvironment(
615
680
  ...common,
616
681
  mode,
617
682
  modelName,
683
+ api: parseModelApi(requiredStringValue(values, "TINKER_API"), "TINKER_API"),
618
684
  apiBase: requiredStringValue(values, "TINKER_BASE_URL"),
619
685
  apiKey: requiredStringValue(values, "TINKER_API_KEY"),
620
686
  contextWindowTokens: requiredNumberValue(values, "TINKER_CONTEXT_WINDOW_TOKENS"),
@@ -16,6 +16,7 @@ import {
16
16
  createWebFetchRefiner,
17
17
  RUNTIME_INSTRUCTIONS,
18
18
  } from "./runner-dependencies";
19
+ import { createReasoningEffortController } from "../model/reasoning-effort";
19
20
  import type { PublicToolingConfig } from "./public-config-contract";
20
21
  import { realpath } from "node:fs/promises";
21
22
  import { loadSkillCatalog } from "../skills/skill-loader";
@@ -51,10 +52,12 @@ export async function runOneShot(
51
52
  runtimeInstructions: RUNTIME_INSTRUCTIONS(workspaceRoot),
52
53
  projectInstructions,
53
54
  });
55
+ const reasoningEffort = createReasoningEffortController(config.reasoning);
54
56
  const modelClient = createRunnerModelClient(
55
57
  config,
56
58
  options.modelClient,
57
59
  options.env,
60
+ reasoningEffort,
58
61
  );
59
62
  session = await createRuntimeSession({
60
63
  selection: { mode: "new", sessionId: config.sessionId },
@@ -72,7 +75,7 @@ export async function runOneShot(
72
75
  presentationSinks: [new StdoutEventPrinter(stdout, stderr)],
73
76
  persistence:
74
77
  options.eventLogPath === false ? false : { eventLogPath: options.eventLogPath },
75
- webFetchRefiner: createWebFetchRefiner(config, options.env),
78
+ webFetchRefiner: createWebFetchRefiner(config, options.env, reasoningEffort),
76
79
  toolingConfig: options.tooling,
77
80
  bashGuard: {
78
81
  mode: config.bashGuardMode,
@@ -2,6 +2,11 @@ import { renderRecallRetirementContract } from "../context/recall-retirement-con
2
2
  import { FakeModelClient } from "../model/fake-model-client";
3
3
  import type { ModelClient } from "../model/model-client";
4
4
  import { OpenAIChatModelClient } from "../model/openai-chat-model-client";
5
+ import { OpenAIResponsesModelClient } from "../model/openai-responses-model-client";
6
+ import {
7
+ createReasoningEffortController,
8
+ type ReasoningEffortController,
9
+ } from "../model/reasoning-effort";
5
10
  import { createModelRefiner, type Refiner } from "../tools/web-fetch/refiner";
6
11
  import type { RunnerConfig } from "./config";
7
12
 
@@ -53,6 +58,8 @@ export function createModelClient(
53
58
  config: Pick<
54
59
  RunnerConfig,
55
60
  | "modelName"
61
+ | "api"
62
+ | "reasoning"
56
63
  | "includeReasoningContent"
57
64
  | "stream"
58
65
  | "contextBudget"
@@ -62,13 +69,19 @@ export function createModelClient(
62
69
  | "tokenEstimator"
63
70
  >,
64
71
  env: NodeJS.ProcessEnv = process.env,
72
+ reasoningEffort?: ReasoningEffortController,
65
73
  ): ModelClient {
74
+ const activeReasoningEffort =
75
+ reasoningEffort ?? createReasoningEffortController(config.reasoning);
66
76
  const fakeMode = env.TINKER_TEST_FAKE_MODEL;
67
77
  if (fakeMode !== undefined && fakeMode !== "") {
68
78
  return new FakeModelClient(fakeMode, {
69
79
  model: config.modelName,
70
80
  contextBudget: config.contextBudget,
71
81
  inputModalities: config.inputModalities,
82
+ ...(activeReasoningEffort === undefined
83
+ ? {}
84
+ : { reasoningEffort: activeReasoningEffort }),
72
85
  ...(config.tokenEstimator === undefined
73
86
  ? {}
74
87
  : { tokenEstimator: config.tokenEstimator }),
@@ -79,17 +92,26 @@ export function createModelClient(
79
92
  });
80
93
  }
81
94
 
82
- return new OpenAIChatModelClient({
95
+ const common = {
83
96
  apiKey: config.apiKey,
84
97
  baseURL: config.apiBase,
85
- includeReasoningContent: config.includeReasoningContent,
86
98
  model: config.modelName,
87
99
  stream: config.stream,
88
100
  contextBudget: config.contextBudget,
89
101
  inputModalities: config.inputModalities,
102
+ ...(activeReasoningEffort === undefined
103
+ ? {}
104
+ : { reasoningEffort: activeReasoningEffort }),
90
105
  ...(config.tokenEstimator === undefined
91
106
  ? {}
92
107
  : { tokenEstimator: config.tokenEstimator }),
108
+ };
109
+ if (config.api === "responses") {
110
+ return new OpenAIResponsesModelClient(common);
111
+ }
112
+ return new OpenAIChatModelClient({
113
+ ...common,
114
+ includeReasoningContent: config.includeReasoningContent,
93
115
  });
94
116
  }
95
117
 
@@ -97,16 +119,18 @@ export function createRunnerModelClient(
97
119
  config: Parameters<typeof createModelClient>[0],
98
120
  injected?: ModelClient,
99
121
  env?: NodeJS.ProcessEnv,
122
+ reasoningEffort?: ReasoningEffortController,
100
123
  ): ModelClient {
101
- return injected ?? createModelClient(config, env);
124
+ return injected ?? createModelClient(config, env, reasoningEffort);
102
125
  }
103
126
 
104
127
  export function createWebFetchRefiner(
105
128
  config: Parameters<typeof createModelClient>[0],
106
129
  env?: NodeJS.ProcessEnv,
130
+ reasoningEffort?: ReasoningEffortController,
107
131
  ): Refiner {
108
132
  return createModelRefiner({
109
- createModelClient: () => createModelClient(config, env),
133
+ createModelClient: () => createModelClient(config, env, reasoningEffort),
110
134
  contextBudget: config.contextBudget,
111
135
  });
112
136
  }
@@ -37,8 +37,12 @@ export async function initializeTuiMemory(input: {
37
37
  return createModelClient(
38
38
  {
39
39
  modelName: profile.model,
40
+ api: profile.api,
40
41
  apiKey: profile.apiKey,
41
42
  apiBase: profile.apiBase,
43
+ ...(profile.reasoning === undefined
44
+ ? {}
45
+ : { reasoning: profile.reasoning }),
42
46
  includeReasoningContent: profile.includeReasoningContent,
43
47
  stream: profile.stream,
44
48
  contextBudget: memoryConfig.contextBudget,
@@ -48,6 +48,7 @@ import { createWorkspaceFileLister } from "../tui/workspace-file-search";
48
48
  import { clipboardWriterForEnvironment } from "../tui/clipboard";
49
49
  import { initializeTuiMemory } from "./tui-memory";
50
50
  import { prepareShikiHighlighter } from "../tui/shiki-highlighter";
51
+ import { createReasoningEffortController } from "../model/reasoning-effort";
51
52
 
52
53
  export type RunTuiOptions = {
53
54
  readonly publicConfig: ResolvedPublicConfig;
@@ -82,10 +83,12 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
82
83
  sessionId: SessionId,
83
84
  sink: EventSink & AssistantTextDeltaSink,
84
85
  ): Promise<RuntimeSession> => {
86
+ const reasoningEffort = createReasoningEffortController(sessionConfig.reasoning);
85
87
  const modelClient = createRunnerModelClient(
86
88
  sessionConfig,
87
89
  undefined,
88
90
  options.env,
91
+ reasoningEffort,
89
92
  );
90
93
  const projectInstructions = await loadProjectInstructions(workspaceRoot);
91
94
  const skillCatalog = await loadSkillCatalog({ workspaceRoot });
@@ -107,7 +110,11 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
107
110
  skillCatalog,
108
111
  presentationSinks: [sink],
109
112
  assistantTextDeltaSink: sink,
110
- webFetchRefiner: createWebFetchRefiner(sessionConfig, options.env),
113
+ webFetchRefiner: createWebFetchRefiner(
114
+ sessionConfig,
115
+ options.env,
116
+ reasoningEffort,
117
+ ),
111
118
  toolingConfig: options.publicConfig.tooling,
112
119
  enableTurnUndo: true,
113
120
  bashGuard: {
@@ -1,5 +1,5 @@
1
1
  import { stableJsonStringify, sha256 } from "../model/model-request-preflight";
2
- import { RECALL_TOOL_DEFINITION } from "../tools/recall";
2
+ import { RECALL_TOOL_DEFINITIONS } from "../tools/recall";
3
3
  import type { ToolDefinition } from "../tools/types";
4
4
  import type { StoredContextSurfaceV8 } from "./context-surface";
5
5
  import {
@@ -17,26 +17,26 @@ export const I4_ACTIVE_RECALL_QUALIFICATION = Object.freeze({
17
17
  policyVersion: "active-recall-qualification-policy-v1",
18
18
  policySha256: "77ca611594d4e9b7b5a597a3a33e35fcaaffae284dc2ac9953ce9a63cce1c009",
19
19
  positiveReportSha256:
20
- "ef84dfee3fdefd7bbf1be2f1641bccc4a1aa06874b22172fecc03dfa2db840d6",
20
+ "e827e5e94171328bb2dd7fcaeff91881f04bdfc78361a45e2548da323229b02a",
21
21
  negativeReportSha256:
22
- "c991980ae2824102174315c9b25c1e4affdbbd22aac320e1a254f2702ba3d1b4",
22
+ "ed379843aee0f193f398a4dff9a18ed338f0edab2cbaf9b628d0dfc402d925a4",
23
23
  resolvedModel: "deepseek-v4-flash",
24
24
  recallContractVersion: CURRENT_RECALL_RETIREMENT_CONTRACT_VERSION,
25
25
  recallContractSha256:
26
- "c75108d351ed3223928ebeb52c7258a6f95033287a8e560fb5b2efff8e084c9b",
26
+ "3b6d1a452efea1db5920eb13542571b038667ef4f635551bea375bda6562a39f",
27
27
  recallToolDefinitionSha256:
28
- "60caf87f313ea35e74e278ab8b30337cc8af1cc83d82d957a73857a764f3e3d9",
28
+ "e63ada7cdf9591d1e933cf5e190ea30586e02bbf73aeb5e648db449d75aae009",
29
29
  metrics: Object.freeze({
30
- fullHistoryTaskSuccessRate: 1,
31
- swapOnlyTaskSuccessRate: 1,
32
- recallOnlyTaskSuccessRate: 0.9667,
33
- recallOnlyActiveRecallRate: 0.9667,
34
- recallOnlySearchGetSuccessRate: 0.3,
35
- minimumCounterfactualGroupTaskSuccessRate: 0.8333,
36
- invalidRecallCallsPerRecallOnlyTrial: 0.0333,
30
+ fullHistoryTaskSuccessRate: 0.9667,
31
+ swapOnlyTaskSuccessRate: 0.9667,
32
+ recallOnlyTaskSuccessRate: 1,
33
+ recallOnlyActiveRecallRate: 1,
34
+ recallOnlySearchGetSuccessRate: 0.3333,
35
+ minimumCounterfactualGroupTaskSuccessRate: 1,
36
+ invalidRecallCallsPerRecallOnlyTrial: 0,
37
37
  negativeUnnecessaryRecallRate: 0,
38
- recallOnlyTokenRatioToFullHistory: 1.1555,
39
- recallOnlyLatencyRatioToFullHistory: 1.365,
38
+ recallOnlyTokenRatioToFullHistory: 1.3739,
39
+ recallOnlyLatencyRatioToFullHistory: 1.207,
40
40
  }),
41
41
  passed: true,
42
42
  } as const);
@@ -80,13 +80,14 @@ export function selectContextAutomation(
80
80
  ) {
81
81
  return disabled("recall_contract_mismatch");
82
82
  }
83
- const recall = input.surface.toolDefinitions.find(
84
- (definition) => definition.name === "Recall",
83
+ const recallTools = input.surface.toolDefinitions.filter(
84
+ (definition) =>
85
+ definition.name === "RecallSearch" || definition.name === "RecallGet",
85
86
  );
86
87
  if (
87
- recall === undefined ||
88
- toolDefinitionHash(recall) !== evidence.recallToolDefinitionSha256 ||
89
- toolDefinitionHash(RECALL_TOOL_DEFINITION) !== evidence.recallToolDefinitionSha256
88
+ recallTools.length !== 2 ||
89
+ toolDefinitionsHash(recallTools) !== evidence.recallToolDefinitionSha256 ||
90
+ toolDefinitionsHash(RECALL_TOOL_DEFINITIONS) !== evidence.recallToolDefinitionSha256
90
91
  ) {
91
92
  return disabled("recall_tool_mismatch");
92
93
  }
@@ -116,6 +117,6 @@ function disabled(
116
117
  });
117
118
  }
118
119
 
119
- function toolDefinitionHash(definition: ToolDefinition): string {
120
- return sha256(stableJsonStringify(definition));
120
+ function toolDefinitionsHash(definitions: readonly ToolDefinition[]): string {
121
+ return sha256(stableJsonStringify(definitions));
121
122
  }
@@ -1,7 +1,7 @@
1
1
  import { ContextBuilder } from "../agent/context-builder";
2
2
  import type { ContextMeter, ContextUsageSnapshot } from "../agent/context-meter";
3
- import type { SessionLedger } from "../agent/session-ledger";
4
- import type { RuntimeIdFactory } from "../ids/runtime-id";
3
+ import type { AgentTurnLedger, SessionLedger } from "../agent/session-ledger";
4
+ import type { RuntimeIdFactory, TurnId } from "../ids/runtime-id";
5
5
  import {
6
6
  CommittedPrefixAuditError,
7
7
  type CommittedPrefixAuditor,
@@ -45,17 +45,25 @@ import {
45
45
  PrefixRetirementPlanner,
46
46
  PrefixRetirementPlanningError,
47
47
  PrefixRetirementPlanStaleError,
48
+ type ActiveTurnBoundary,
49
+ type ClosedTurnBoundary,
48
50
  type PrefixRetirementPlan,
49
51
  } from "./prefix-retirement-planner";
50
52
 
51
53
  export type ContextCompactionTrigger =
52
54
  | { kind: "manual" }
53
- | { kind: "runtime_pressure" }
55
+ | {
56
+ kind: "runtime_pressure";
57
+ activeTurn?: {
58
+ turnId: TurnId;
59
+ consumedThroughOrdinal: number;
60
+ };
61
+ }
54
62
  | { kind: "benchmark_forced"; targetTokens: number };
55
63
 
56
64
  export type ContextRetirementTrigger =
57
65
  | { kind: "manual" }
58
- | { kind: "runtime_pressure" }
66
+ | { kind: "runtime_pressure"; activeTurnId?: TurnId }
59
67
  | { kind: "benchmark_forced"; targetTokens: number };
60
68
 
61
69
  export type ContextCompactionResult =
@@ -246,15 +254,32 @@ export class ContextManager {
246
254
  this.retirementPlanner = new PrefixRetirementPlanner(input.model);
247
255
  }
248
256
 
249
- async compact(trigger: ContextCompactionTrigger): Promise<ContextCompactionResult> {
257
+ measureCurrent(
258
+ activeTurnId?: TurnId,
259
+ activeLedger?: AgentTurnLedger,
260
+ ): ContextUsageSnapshot {
261
+ assertActiveLedger(activeTurnId, activeLedger);
262
+ this.input.store.assertContextRevisionBoundary(activeTurnId);
263
+ const tools = this.input.tools();
264
+ const built = buildCurrentRequest(this.input.ledger, activeLedger, tools);
265
+ const prepared = this.input.model.prepare(built.request);
266
+ this.input.committedPrefixAuditor.audit(built.compiled.revisionId, prepared);
267
+ return this.input.contextMeter.measure(prepared);
268
+ }
269
+
270
+ async compact(
271
+ trigger: ContextCompactionTrigger,
272
+ activeLedger?: AgentTurnLedger,
273
+ ): Promise<ContextCompactionResult> {
274
+ assertActiveLedger(activeTurnId(trigger), activeLedger);
250
275
  const startedAt = performance.now();
251
276
  let built;
252
277
  let activePrepared;
253
278
  let activeUsage;
254
279
  const tools = this.input.tools();
255
280
  try {
256
- this.input.store.assertContextRevisionIdle();
257
- built = this.input.ledger.buildCommittedModelRequest(tools);
281
+ this.input.store.assertContextRevisionBoundary(activeTurnId(trigger));
282
+ built = buildCurrentRequest(this.input.ledger, activeLedger, tools);
258
283
  activePrepared = this.input.model.prepare(built.request);
259
284
  activeUsage = this.input.contextMeter.measure(activePrepared);
260
285
  } catch (error) {
@@ -274,6 +299,9 @@ export class ContextManager {
274
299
  tools,
275
300
  policy: swapOnlyPolicyV1,
276
301
  trigger: trigger.kind,
302
+ ...(trigger.kind === "runtime_pressure" && trigger.activeTurn !== undefined
303
+ ? { activeTurn: trigger.activeTurn }
304
+ : {}),
277
305
  ...(trigger.kind === "benchmark_forced"
278
306
  ? { forcedTargetTokens: trigger.targetTokens }
279
307
  : {}),
@@ -357,7 +385,7 @@ export class ContextManager {
357
385
  candidatePrepared = this.input.model.prepare(candidate.request);
358
386
  assertCandidatePrepared(activePrepared, candidatePrepared, plan);
359
387
 
360
- const current = this.input.ledger.buildCommittedModelRequest(tools);
388
+ const current = buildCurrentRequest(this.input.ledger, activeLedger, tools);
361
389
  const currentPrepared = this.input.model.prepare(current.request);
362
390
  assertPlanBaseCurrent(plan, {
363
391
  active: current.compiled,
@@ -365,7 +393,7 @@ export class ContextManager {
365
393
  activeOverrides: current.activeOverrides,
366
394
  activePrepared: currentPrepared,
367
395
  });
368
- this.input.store.assertContextRevisionIdle();
396
+ this.input.store.assertContextRevisionBoundary(activeTurnId(trigger));
369
397
  } catch (error) {
370
398
  throw managerError("validate", error);
371
399
  }
@@ -394,6 +422,9 @@ export class ContextManager {
394
422
  candidateCompiled.entries,
395
423
  plan.baseCanonicalThroughOrdinal,
396
424
  ),
425
+ ...(activeTurnId(trigger) === undefined
426
+ ? {}
427
+ : { activeTurnId: activeTurnId(trigger) }),
397
428
  });
398
429
  } catch (error) {
399
430
  throw managerError("commit", error);
@@ -402,6 +433,7 @@ export class ContextManager {
402
433
 
403
434
  const activationStartedAt = performance.now();
404
435
  try {
436
+ activeLedger?.activateContextSnapshot(this.input.store.loadContextSnapshot());
405
437
  this.input.contextMeter.startRevision({
406
438
  reason: "context_rebuilt",
407
439
  requestConfigHash: candidatePrepared.requestConfigHash,
@@ -454,19 +486,26 @@ export class ContextManager {
454
486
 
455
487
  async retirePrefix(
456
488
  trigger: ContextRetirementTrigger,
489
+ activeLedger?: AgentTurnLedger,
457
490
  ): Promise<ContextRetirementResult> {
491
+ assertActiveLedger(activeTurnId(trigger), activeLedger);
458
492
  const startedAt = performance.now();
459
493
  const tools = this.input.tools();
460
494
  let built;
461
495
  let activePrepared;
462
496
  let activeUsage;
463
- let closedTurns;
497
+ let closedTurns: readonly ClosedTurnBoundary[];
498
+ let activeTurn: ActiveTurnBoundary | undefined;
464
499
  try {
465
- this.input.store.assertContextRevisionIdle();
466
- built = this.input.ledger.buildCommittedModelRequest(tools);
500
+ this.input.store.assertContextRevisionBoundary(activeTurnId(trigger));
501
+ built = buildCurrentRequest(this.input.ledger, activeLedger, tools);
467
502
  activePrepared = this.input.model.prepare(built.request);
468
503
  activeUsage = this.input.contextMeter.measure(activePrepared);
469
- closedTurns = this.input.store.loadClosedTurnBoundaries();
504
+ const boundaries = this.input.store.loadRetirementBoundaries(
505
+ activeTurnId(trigger),
506
+ );
507
+ closedTurns = boundaries.closedTurns;
508
+ activeTurn = boundaries.activeTurn;
470
509
  } catch (error) {
471
510
  throw managerError("snapshot", error);
472
511
  }
@@ -480,6 +519,7 @@ export class ContextManager {
480
519
  activeOverrides: built.activeOverrides,
481
520
  canonical: built.canonical,
482
521
  closedTurns,
522
+ ...(activeTurn === undefined ? {} : { activeTurn }),
483
523
  activePrepared,
484
524
  activeUsage,
485
525
  tools,
@@ -547,7 +587,7 @@ export class ContextManager {
547
587
  candidatePrepared = this.input.model.prepare(candidate.request);
548
588
  assertRetirementCandidatePrepared(activePrepared, candidatePrepared, plan);
549
589
 
550
- const current = this.input.ledger.buildCommittedModelRequest(tools);
590
+ const current = buildCurrentRequest(this.input.ledger, activeLedger, tools);
551
591
  const currentPrepared = this.input.model.prepare(current.request);
552
592
  assertRetirementPlanBaseCurrent(plan, {
553
593
  active: current.compiled,
@@ -557,7 +597,7 @@ export class ContextManager {
557
597
  canonical: current.canonical,
558
598
  activePrepared: currentPrepared,
559
599
  });
560
- this.input.store.assertContextRevisionIdle();
600
+ this.input.store.assertContextRevisionBoundary(activeTurnId(trigger));
561
601
  } catch (error) {
562
602
  throw managerError("validate", error);
563
603
  }
@@ -586,6 +626,9 @@ export class ContextManager {
586
626
  nextActiveOverrideManifestSha256: plan.nextActiveOverrideManifestSha256,
587
627
  canonicalSequenceSha256: plan.canonicalSequenceSha256,
588
628
  renderedMessageSha256: plan.renderedMessageSha256,
629
+ ...(activeTurnId(trigger) === undefined
630
+ ? {}
631
+ : { activeTurnId: activeTurnId(trigger) }),
589
632
  });
590
633
  } catch (error) {
591
634
  throw managerError("commit", error);
@@ -594,6 +637,7 @@ export class ContextManager {
594
637
 
595
638
  const activationStartedAt = performance.now();
596
639
  try {
640
+ activeLedger?.activateContextSnapshot(this.input.store.loadContextSnapshot());
597
641
  this.input.contextMeter.startRevision({
598
642
  reason: "context_rebuilt",
599
643
  requestConfigHash: candidatePrepared.requestConfigHash,
@@ -739,3 +783,35 @@ function errorCode(error: unknown): string {
739
783
  function elapsedMs(startedAt: number): number {
740
784
  return Math.round((performance.now() - startedAt) * 100) / 100;
741
785
  }
786
+
787
+ function activeTurnId(
788
+ trigger: ContextCompactionTrigger | ContextRetirementTrigger,
789
+ ): TurnId | undefined {
790
+ if (trigger.kind !== "runtime_pressure") {
791
+ return undefined;
792
+ }
793
+ if ("activeTurnId" in trigger) {
794
+ return trigger.activeTurnId;
795
+ }
796
+ return (trigger as Extract<ContextCompactionTrigger, { kind: "runtime_pressure" }>)
797
+ .activeTurn?.turnId;
798
+ }
799
+
800
+ function assertActiveLedger(
801
+ activeTurnId: TurnId | undefined,
802
+ activeLedger: AgentTurnLedger | undefined,
803
+ ): void {
804
+ if ((activeTurnId === undefined) !== (activeLedger === undefined)) {
805
+ throw new Error("Active context maintenance requires its active turn ledger.");
806
+ }
807
+ }
808
+
809
+ function buildCurrentRequest(
810
+ ledger: SessionLedger,
811
+ activeLedger: AgentTurnLedger | undefined,
812
+ tools: readonly ToolDefinition[],
813
+ ) {
814
+ return activeLedger === undefined
815
+ ? ledger.buildCommittedModelRequest(tools)
816
+ : activeLedger.buildModelRequest(tools);
817
+ }
@@ -1,7 +1,6 @@
1
1
  export const swapOnlyPolicyV1 = Object.freeze({
2
2
  version: "swap-only-v1",
3
3
  minimumObservationBytes: 8 * 1_024,
4
- protectedRecentTurnCount: 8,
5
4
  targetInputRatio: 0.3,
6
5
  } as const);
7
6
 
@@ -9,7 +8,6 @@ export type SwapOnlyPolicyV1 = typeof swapOnlyPolicyV1;
9
8
 
10
9
  export const recallFirstRetirementPolicyV1 = Object.freeze({
11
10
  version: "recall-first-retirement-v1",
12
- protectedRecentTurnCount: 8,
13
11
  targetInputRatio: 0.3,
14
12
  } as const);
15
13
 
@@ -73,7 +73,7 @@ export class ContextSwapRenderer {
73
73
  `contentSha256=${message.contentSha256}`,
74
74
  `tool=${stableJsonStringify(compactExternalString(message.name))}`,
75
75
  `metadata=${metadata}`,
76
- "historical=Use Recall get with source to recover the original observation.",
76
+ "historical=Use RecallGet with source to recover the original observation.",
77
77
  `current=${currentGuidance(raw.kind)}`,
78
78
  ].join("\n");
79
79
  const originalBytes = utf8Bytes(message.content);