@lensagent/core 0.1.0 → 0.1.2

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 (99) hide show
  1. package/README.md +63 -4
  2. package/config/lens.example.jsonc +36 -13
  3. package/dist/adapters/executor-router.d.ts +20 -0
  4. package/dist/adapters/executor-router.js +86 -0
  5. package/dist/adapters/executor.d.ts +9 -3
  6. package/dist/adapters/executor.js +196 -61
  7. package/dist/adapters/lsp.d.ts +2 -1
  8. package/dist/adapters/lsp.js +98 -41
  9. package/dist/adapters/mcp.js +52 -17
  10. package/dist/adapters/revert.d.ts +5 -4
  11. package/dist/adapters/revert.js +6 -64
  12. package/dist/adapters/web.d.ts +9 -0
  13. package/dist/adapters/web.js +244 -0
  14. package/dist/config/files.d.ts +14 -0
  15. package/dist/config/files.js +57 -0
  16. package/dist/config/runtime.d.ts +3 -4
  17. package/dist/config/runtime.js +116 -97
  18. package/dist/config/schema.d.ts +56 -10
  19. package/dist/config/schema.js +48 -8
  20. package/dist/contracts/capability.d.ts +27 -1
  21. package/dist/contracts/domain.d.ts +1 -0
  22. package/dist/contracts/model.d.ts +33 -6
  23. package/dist/contracts/review.d.ts +17 -0
  24. package/dist/contracts/review.js +1 -0
  25. package/dist/core/access-policy.d.ts +40 -0
  26. package/dist/core/access-policy.js +68 -0
  27. package/dist/core/access.d.ts +30 -2
  28. package/dist/core/access.js +159 -26
  29. package/dist/core/context-boundaries.d.ts +25 -0
  30. package/dist/core/context-boundaries.js +106 -0
  31. package/dist/core/context.d.ts +3 -0
  32. package/dist/core/context.js +11 -0
  33. package/dist/core/host.js +261 -173
  34. package/dist/entry/pair.d.ts +1 -0
  35. package/dist/entry/pair.js +14 -0
  36. package/dist/entry/run.js +1 -1
  37. package/dist/execution/lsp-process.d.ts +3 -2
  38. package/dist/execution/lsp-process.js +46 -28
  39. package/dist/http/server.d.ts +5 -0
  40. package/dist/http/server.js +262 -80
  41. package/dist/runtime/agent.d.ts +9 -4
  42. package/dist/runtime/agent.js +361 -286
  43. package/dist/runtime/artifacts.d.ts +13 -1
  44. package/dist/runtime/artifacts.js +137 -10
  45. package/dist/runtime/cache.d.ts +59 -16
  46. package/dist/runtime/cache.js +124 -43
  47. package/dist/runtime/calls.d.ts +1 -1
  48. package/dist/runtime/calls.js +164 -48
  49. package/dist/runtime/capabilities.d.ts +2 -2
  50. package/dist/runtime/capabilities.js +4 -5
  51. package/dist/runtime/cleanup.d.ts +5 -2
  52. package/dist/runtime/cleanup.js +48 -17
  53. package/dist/runtime/compaction.d.ts +9 -0
  54. package/dist/runtime/compaction.js +52 -0
  55. package/dist/runtime/context.d.ts +8 -4
  56. package/dist/runtime/context.js +115 -128
  57. package/dist/runtime/events.d.ts +7 -0
  58. package/dist/runtime/events.js +83 -0
  59. package/dist/runtime/images.d.ts +14 -0
  60. package/dist/runtime/images.js +82 -0
  61. package/dist/runtime/kernel-capabilities.d.ts +7 -1
  62. package/dist/runtime/kernel-capabilities.js +146 -68
  63. package/dist/runtime/model-audit.d.ts +9 -0
  64. package/dist/runtime/model-audit.js +39 -0
  65. package/dist/runtime/model.d.ts +109 -4
  66. package/dist/runtime/model.js +368 -143
  67. package/dist/runtime/observability.d.ts +1 -0
  68. package/dist/runtime/observability.js +60 -15
  69. package/dist/runtime/policy.d.ts +1 -1
  70. package/dist/runtime/policy.js +2 -2
  71. package/dist/runtime/profile.d.ts +2 -1
  72. package/dist/runtime/profile.js +12 -15
  73. package/dist/runtime/projects.d.ts +3 -1
  74. package/dist/runtime/projects.js +38 -17
  75. package/dist/runtime/ptc-worker.d.ts +1 -0
  76. package/dist/runtime/ptc-worker.js +97 -0
  77. package/dist/runtime/ptc.d.ts +16 -0
  78. package/dist/runtime/ptc.js +128 -0
  79. package/dist/runtime/results.d.ts +5 -4
  80. package/dist/runtime/results.js +29 -51
  81. package/dist/runtime/review.d.ts +4 -1
  82. package/dist/runtime/review.js +101 -22
  83. package/dist/runtime/scheduler.js +1 -0
  84. package/dist/runtime/skills.js +34 -5
  85. package/dist/runtime/task-audit.d.ts +21 -0
  86. package/dist/runtime/task-audit.js +122 -0
  87. package/dist/runtime/time.d.ts +9 -0
  88. package/dist/runtime/time.js +18 -0
  89. package/dist/runtime/tokenizer.d.ts +2 -0
  90. package/dist/runtime/tokenizer.js +26 -1
  91. package/dist/store/database.js +26 -1
  92. package/dist/store/event-log.d.ts +1 -0
  93. package/dist/store/event-log.js +22 -5
  94. package/dist/store/schema.d.ts +1 -1
  95. package/dist/store/schema.js +50 -1
  96. package/dist/store/store.d.ts +65 -19
  97. package/dist/store/store.js +986 -188
  98. package/package.json +18 -8
  99. package/proto/executor.proto +69 -55
@@ -1,18 +1,29 @@
1
+ import { assertContextBoundaries } from '../core/context-boundaries.js';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { sha256 } from './model-audit.js';
1
4
  import { createAnthropic } from '@ai-sdk/anthropic';
2
5
  import { createOpenAI } from '@ai-sdk/openai';
3
6
  import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
4
- import { jsonSchema, stepCountIs, streamText, tool } from 'ai';
5
- import { jsonValue, toolArguments } from '../core/codec.js';
7
+ import { jsonSchema, streamText, tool } from 'ai';
8
+ import { canonicalize } from 'json-canonicalize';
9
+ import { digest, jsonValue, toolArguments } from '../core/codec.js';
6
10
  import { fail } from '../core/domain.js';
7
11
  import { resolveSecret } from '../config/runtime.js';
8
- import { resolveProfile } from './profile.js';
12
+ import { resolveProfile, resolveReasoningEffort } from './profile.js';
13
+ import { record, selectCacheBreakpoints } from './cache.js';
14
+ import { estimateTokens } from './tokenizer.js';
15
+ import { imageMessages, imageReference, imageTokenEstimate, materializeImages } from './images.js';
9
16
  import { assertProviderManifest, providerManifest } from './manifests.js';
10
17
  export class AiModelGateway {
11
18
  config;
12
19
  target;
13
- constructor(config, target) {
20
+ artifacts;
21
+ audit;
22
+ constructor(config, target, artifacts, audit) {
14
23
  this.config = config;
15
24
  this.target = target;
25
+ this.artifacts = artifacts;
26
+ this.audit = audit;
16
27
  }
17
28
  resolve(reference, config = this.config.current.config) {
18
29
  const { providerId, modelId } = splitReference(reference);
@@ -26,177 +37,363 @@ export class AiModelGateway {
26
37
  contextWindowTokens: model.contextWindowTokens,
27
38
  profile: resolveProfile(config, model.harnessProfile),
28
39
  usageReliable: model.usageReliable ?? false,
29
- cacheSupported: model.cacheSupported ?? false,
30
- programmaticToolCallingSupported: model.programmaticToolCallingSupported ?? false
40
+ cacheSupported: (model.promptCache ?? provider.promptCache)?.mode !== undefined ? (model.promptCache ?? provider.promptCache)?.mode !== 'off' : model.cacheSupported ?? false
31
41
  };
32
42
  }
33
- async *stream(request, config = this.config.current.config) {
43
+ async *stream(request, config = this.config.current.config, compiled = compileModelRequest(request, config)) {
44
+ assertContextBoundaries(request.messages);
34
45
  const { providerId, modelId } = splitReference(request.model);
35
46
  const providerConfig = config.providers[providerId];
36
47
  const modelConfig = providerConfig?.models[modelId];
37
48
  if (providerConfig === undefined || modelConfig === undefined)
38
49
  fail('MODEL_NOT_FOUND', 404, `Model ${request.model} is not configured.`);
39
50
  assertProviderManifest(providerManifest(config, providerId), request.model, this.target);
40
- const resolved = createModel(providerId, modelConfig.wireModel ?? modelId, providerConfig);
41
- const programmatic = request.programmaticToolCalling;
42
- if (programmatic !== undefined && resolved.programmaticTool === undefined) {
43
- fail('ADAPTER_INCOMPATIBLE', 503, 'The configured Provider Adapter does not support hosted Programmatic Tool Calling.');
44
- }
45
- const callers = new Map();
51
+ const requestId = randomUUID();
52
+ const report = this.audit === undefined ? undefined : (event) => {
53
+ this.audit?.append({ ...event, model_request_id: requestId, context: request.auditContext ?? null,
54
+ provider_id: providerId, model_id: modelConfig.wireModel ?? modelId,
55
+ input_manifest: request.messages.map(message => ({ role: message.role, context_item_id: message.contextItemId ?? null,
56
+ content_sha256: sha256(message.content), content_bytes: Buffer.byteLength(message.content),
57
+ tool_call_id: message.toolCallId ?? null, images: message.images ?? [] })) });
58
+ };
59
+ const resolved = createModel(providerId, modelConfig.wireModel ?? modelId, providerConfig, compiled.bodyOptions, report);
46
60
  const ordinals = new Map();
47
- const toolSet = tools(request.tools, programmatic, resolved.programmaticTool, callers, ordinals);
48
- const instructions = request.messages.filter((message) => message.role === 'system').map((message) => message.content).join('\n\n');
49
- const conversation = request.messages.filter((message) => message.role !== 'system');
50
61
  const result = streamText({
51
62
  model: resolved.model,
52
- ...(instructions.length === 0 ? {} : { instructions }),
53
- ...(conversation.length === 0 ? { prompt: '' } : { messages: conversation.map(aiMessage) }),
54
- tools: toolSet,
55
- ...(programmatic === undefined ? {} : {
56
- experimental_toolCallers: Object.fromEntries(programmatic.capabilityIds
57
- .filter((capabilityId) => capabilityId !== 'programmatic_tool_run' && capabilityId !== 'capability_search')
58
- .map((capabilityId) => [capabilityId, ['programmatic_tool_run', 'AI_SDK_DIRECT_TOOL_CALL']])),
59
- stopWhen: stepCountIs(programmatic.maxToolCalls + 1),
60
- onToolExecutionStart: (event) => {
61
- const caller = providerCaller(event.toolCall.providerMetadata);
62
- if (caller.parentProviderCallId !== null)
63
- callers.set(event.toolCall.toolCallId, caller.parentProviderCallId);
64
- }
65
- }),
63
+ allowSystemInMessages: true,
64
+ messages: await materializeImages(compiled.messages, request.messages, this.artifacts),
65
+ tools: tools(compiled.tools),
66
+ providerOptions: compiled.providerOptions,
67
+ toolChoice: compiled.toolChoice,
66
68
  maxRetries: 0,
67
- ...(request.maxOutputTokens === undefined ? {} : { maxOutputTokens: request.maxOutputTokens }),
68
- abortSignal: request.signal,
69
- ...(request.reasoningEffort === null ? {} : { providerOptions: reasoningOptions(providerId, providerConfig.adapter, request.reasoningEffort) })
69
+ ...(compiled.maxOutputTokens === null ? {} : { maxOutputTokens: compiled.maxOutputTokens }),
70
+ abortSignal: request.signal
70
71
  });
71
- let ordinal = 0;
72
- for await (const part of result.stream) {
73
- if (part.type === 'text-delta')
74
- yield { type: 'text', text: part.text };
75
- else if (part.type === 'reasoning-delta')
76
- yield { type: 'thinking', text: part.text };
77
- else if (part.type === 'tool-input-start') {
78
- const toolOrdinal = ordinal++;
79
- ordinals.set(part.id, toolOrdinal);
80
- yield { type: 'toolStart', ordinal: toolOrdinal, id: part.id, name: part.toolName };
81
- }
82
- else if (part.type === 'tool-call') {
83
- const toolOrdinal = ordinals.get(part.toolCallId) ?? ordinal++;
84
- ordinals.set(part.toolCallId, toolOrdinal);
85
- const caller = providerCaller(part.providerMetadata);
86
- yield {
87
- type: 'tool',
88
- ordinal: toolOrdinal,
89
- id: part.toolCallId,
90
- name: part.toolName,
91
- // Not `jsonValue`: a Provider hands back a raw JSON string when the model calls a tool that was
92
- // not in the request's manifest, and storing that string poisons the Context permanently.
93
- arguments: toolArguments(part.input),
94
- ...(caller.parentProviderCallId === null ? {} : { parentProviderCallId: caller.parentProviderCallId }),
95
- ...(part.providerExecuted === true ? { providerExecuted: true } : {}),
96
- ...(caller.programmatic ? { programmaticCaller: true } : {})
97
- };
98
- }
99
- else if (part.type === 'tool-result') {
100
- const toolOrdinal = ordinals.get(part.toolCallId) ?? ordinal++;
101
- ordinals.set(part.toolCallId, toolOrdinal);
102
- yield {
103
- type: 'toolResult',
104
- ordinal: toolOrdinal,
105
- id: part.toolCallId,
106
- name: part.toolName,
107
- output: jsonValue(part.output),
108
- ...(part.providerExecuted === true ? { providerExecuted: true } : {})
109
- };
110
- }
111
- else if (part.type === 'finish') {
112
- yield {
113
- type: 'usage',
114
- inputTokens: part.totalUsage.inputTokens ?? 0,
115
- outputTokens: part.totalUsage.outputTokens ?? 0
116
- };
117
- yield { type: 'end', reason: part.finishReason };
118
- }
119
- else if (part.type === 'error') {
120
- throw part.error;
121
- }
122
- else if (part.type === 'abort') {
123
- throw new Error(part.reason ?? 'Model request aborted.');
72
+ let streamCompleted = false;
73
+ try {
74
+ let ordinal = 0;
75
+ let finishReason;
76
+ const openBlocks = new Set();
77
+ let reportedCache;
78
+ for await (const part of result.stream) {
79
+ if (part.type === 'text-start')
80
+ openBlocks.add(`text:${part.id}`);
81
+ else if (part.type === 'text-end')
82
+ openBlocks.delete(`text:${part.id}`);
83
+ else if (part.type === 'reasoning-start')
84
+ openBlocks.add(`reasoning:${part.id}`);
85
+ else if (part.type === 'reasoning-end')
86
+ openBlocks.delete(`reasoning:${part.id}`);
87
+ else if (part.type === 'tool-input-start')
88
+ openBlocks.add(`tool:${part.id}`);
89
+ else if (part.type === 'tool-input-end')
90
+ openBlocks.delete(`tool:${part.id}`);
91
+ if (part.type === 'start-step' && part.warnings.length > 0)
92
+ yield { type: 'providerWarnings', warnings: jsonValue(part.warnings) };
93
+ else if (part.type === 'text-delta')
94
+ yield { type: 'text', text: part.text };
95
+ else if (part.type === 'reasoning-delta')
96
+ yield { type: 'thinking', text: part.text };
97
+ else if (part.type === 'tool-input-start') {
98
+ const toolOrdinal = ordinal++;
99
+ ordinals.set(part.id, toolOrdinal);
100
+ yield { type: 'toolStart', ordinal: toolOrdinal, id: part.id, name: part.toolName };
101
+ }
102
+ else if (part.type === 'tool-call') {
103
+ const toolOrdinal = ordinals.get(part.toolCallId) ?? ordinal++;
104
+ ordinals.set(part.toolCallId, toolOrdinal);
105
+ yield {
106
+ type: 'tool',
107
+ ordinal: toolOrdinal,
108
+ id: part.toolCallId,
109
+ name: part.toolName,
110
+ // Not `jsonValue`: a Provider hands back a raw JSON string when the model calls a tool that was
111
+ // not in the request's manifest, and storing that string poisons the Context permanently.
112
+ arguments: toolArguments(part.input),
113
+ ...(part.providerExecuted === true ? { providerExecuted: true } : {}),
114
+ };
115
+ }
116
+ else if (part.type === 'tool-result') {
117
+ const toolOrdinal = ordinals.get(part.toolCallId) ?? ordinal++;
118
+ ordinals.set(part.toolCallId, toolOrdinal);
119
+ yield {
120
+ type: 'toolResult',
121
+ ordinal: toolOrdinal,
122
+ id: part.toolCallId,
123
+ name: part.toolName,
124
+ output: jsonValue(part.output),
125
+ ...(part.providerExecuted === true ? { providerExecuted: true } : {})
126
+ };
127
+ }
128
+ else if (part.type === 'finish-step' && providerConfig.adapter !== '@ai-sdk/openai-compatible') {
129
+ const raw = record(part.usage.raw);
130
+ const details = record(raw?.input_tokens_details);
131
+ const read = providerConfig.adapter === '@ai-sdk/openai' ? details?.cached_tokens : raw?.cache_read_input_tokens;
132
+ const write = providerConfig.adapter === '@ai-sdk/openai' ? details?.cache_write_tokens : raw?.cache_creation_input_tokens;
133
+ const noCache = providerConfig.adapter === '@ai-sdk/anthropic' ? raw?.input_tokens : typeof read === 'number' ? part.usage.inputTokenDetails.noCacheTokens : undefined;
134
+ reportedCache = { ...(typeof read === 'number' ? { cacheReadTokens: read } : {}), ...(typeof write === 'number' ? { cacheWriteTokens: write } : {}), ...(typeof noCache === 'number' ? { uncachedInputTokens: noCache } : {}) };
135
+ }
136
+ else if (part.type === 'finish') {
137
+ yield {
138
+ type: 'usage',
139
+ inputTokens: part.totalUsage.inputTokens ?? 0,
140
+ outputTokens: part.totalUsage.outputTokens ?? 0,
141
+ ...(reportedCache ?? {
142
+ ...(part.totalUsage.inputTokenDetails.cacheReadTokens === undefined ? {} : { cacheReadTokens: part.totalUsage.inputTokenDetails.cacheReadTokens }),
143
+ ...(part.totalUsage.inputTokenDetails.cacheWriteTokens === undefined ? {} : { cacheWriteTokens: part.totalUsage.inputTokenDetails.cacheWriteTokens }),
144
+ ...(part.totalUsage.inputTokenDetails.noCacheTokens === undefined ? {} : { uncachedInputTokens: part.totalUsage.inputTokenDetails.noCacheTokens })
145
+ })
146
+ };
147
+ finishReason = part.finishReason;
148
+ }
149
+ else if (part.type === 'error') {
150
+ throw part.error;
151
+ }
152
+ else if (part.type === 'abort') {
153
+ throw new Error(part.reason ?? 'Model request aborted.');
154
+ }
124
155
  }
156
+ request.signal.throwIfAborted();
157
+ if (finishReason !== 'stop' && finishReason !== 'tool-calls')
158
+ fail('MODEL_RESPONSE_INCOMPLETE', 502, `Model response did not complete: ${finishReason ?? 'missing finish'}.`);
159
+ if (openBlocks.size > 0)
160
+ fail('MODEL_RESPONSE_INCOMPLETE', 502, 'Model response contains unfinished content blocks.');
161
+ const responses = await result.responseMessages;
162
+ const content = responses.flatMap(message => message.role === 'assistant' && Array.isArray(message.content) ? message.content : []);
163
+ // JSON serialization removes SDK optional undefined fields without interpreting opaque metadata.
164
+ const response = JSON.parse(JSON.stringify({ route: responseRoute(providerId, providerConfig, modelId), content }));
165
+ assertReasoningState(response, providerConfig.adapter, resolveProfile(config, modelConfig.harnessProfile));
166
+ report?.({ event_type: 'model_stream_completed', outcome: 'validated_response', finish_reason: finishReason });
167
+ streamCompleted = true;
168
+ yield { type: 'response', response };
169
+ yield { type: 'end', reason: finishReason };
170
+ }
171
+ finally {
172
+ if (!streamCompleted)
173
+ report?.({ event_type: 'model_stream_incomplete', outcome: 'unknown', cancelled: request.signal.aborted });
125
174
  }
126
175
  }
127
176
  }
128
- function reasoningOptions(providerId, adapter, effort) {
177
+ // This representation is shared by attempt diagnostics and the SDK send path.
178
+ // Local Context identities and annotations never enter the Provider fingerprint.
179
+ export function compileModelRequest(request, config, previous) {
180
+ assertContextBoundaries(request.messages);
181
+ const { providerId, modelId } = splitReference(request.model);
182
+ const provider = config.providers[providerId];
183
+ const model = provider?.models[modelId];
184
+ if (provider === undefined || model === undefined)
185
+ fail('MODEL_NOT_FOUND', 404, `Model ${request.model} is not configured.`);
186
+ const profile = resolveProfile(config, model.harnessProfile);
187
+ const reasoningEffort = request.reasoningEffort === null ? null : resolveReasoningEffort(config, request.model, request.reasoningEffort);
188
+ const bodyOptions = reasoningBody(provider.adapter, reasoningEffort);
189
+ if (profile.value.semantics.reasoningStateRoundTrip === 'required' && provider.adapter === '@ai-sdk/openai-compatible') {
190
+ fail('MODEL_REASONING_UNSUPPORTED', 400, 'This adapter cannot guarantee required reasoning state round trips.');
191
+ }
192
+ const route = responseRoute(providerId, provider, modelId);
193
+ for (const message of request.messages) {
194
+ if (message.response === undefined) {
195
+ if (profile.value.semantics.reasoningStateRoundTrip === 'required' && message.toolCalls?.length)
196
+ fail('MODEL_REASONING_STATE_MISSING', 409, 'Historical tool calls have no complete Provider response state.');
197
+ continue;
198
+ }
199
+ if (message.response.content.some(part => part.type === 'reasoning' || part.type === 'reasoning-file')) {
200
+ if (message.response.route !== route)
201
+ fail('MODEL_REASONING_STATE_INCOMPATIBLE', 409, 'Reasoning state cannot be replayed on a different Provider or model route.');
202
+ assertReasoningState(message.response, provider.adapter, profile);
203
+ }
204
+ }
205
+ const settings = model.promptCache ?? provider.promptCache;
206
+ const enabled = config.providerPromptCache?.enabled ?? profile.value.cache.enabledByDefault;
207
+ const mode = enabled ? settings?.mode ?? (model.cacheSupported === true ? 'implicit' : 'off') : 'off';
208
+ if ((mode === 'anthropic' && provider.adapter !== '@ai-sdk/anthropic') || (mode === 'openai-explicit' && provider.adapter !== '@ai-sdk/openai')) {
209
+ fail('CONFIG_INVALID', 400, `Cache mode ${mode} does not match ${provider.adapter}.`);
210
+ }
211
+ if ((mode === 'anthropic' && settings?.ttl === '30m') || (mode === 'openai-explicit' && settings?.ttl !== undefined && settings.ttl !== '30m')) {
212
+ fail('CONFIG_INVALID', 400, `Cache TTL does not match ${mode}.`);
213
+ }
214
+ const routeDigest = digest({ providerId, model: model.wireModel ?? modelId, adapter: provider.adapter, options: provider.options });
215
+ const rawTools = request.tools.map(({ id, description, inputSchema }) => ({ id, description, inputSchema })).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
216
+ const systems = request.messages.filter((message) => message.role === 'system');
217
+ const logical = [...systems, ...imageMessages(request.messages.filter((message) => message.role !== 'system'))];
218
+ const baselineSource = { systems: systems.map(({ role, content }) => ({ role, content })), tools: rawTools };
219
+ const sourceDigest = digest(baselineSource);
220
+ const prior = record(previous?.request);
221
+ const savedBaseline = record(prior?.baseline);
222
+ const reuseBaseline = prior?.routeDigest === routeDigest && savedBaseline?.sourceDigest === sourceDigest;
223
+ // Restore the actual finalized header, not a newly rendered variant, on restart/retry.
224
+ const baseline = reuseBaseline ? savedBaseline
225
+ : { sourceDigest, messages: JSON.parse(canonicalize(baselineSource.systems)), tools: JSON.parse(canonicalize(rawTools)) };
226
+ const definitions = [...baseline.tools];
227
+ const messages = JSON.parse(canonicalize([
228
+ ...baseline.messages, ...logical.filter((message) => message.role !== 'system').map(message => aiMessage(message, route))
229
+ ]));
230
+ if (mode === 'openai-explicit') {
231
+ for (const [index, message] of messages.entries()) {
232
+ if (message.role !== 'tool')
233
+ continue;
234
+ messages[index] = { ...message, content: message.content.map((part) => {
235
+ if (part.type !== 'tool-result')
236
+ return part;
237
+ const text = part.output.type === 'json' ? canonicalize(part.output.value) : part.output.type === 'text' ? part.output.value : '';
238
+ return { ...part, output: { type: 'content', value: [{ type: 'text', text }] } };
239
+ }) };
240
+ }
241
+ }
242
+ const namespace = provider.adapter === '@ai-sdk/openai' ? 'openai' : provider.adapter === '@ai-sdk/anthropic' ? 'anthropic' : providerId;
243
+ const providerOptions = {};
244
+ if (provider.adapter === '@ai-sdk/openai')
245
+ providerOptions.openai = { ...providerOptions.openai, store: false, include: ['reasoning.encrypted_content'] };
246
+ if (mode !== 'off' && provider.adapter === '@ai-sdk/openai') {
247
+ providerOptions[namespace] = {
248
+ ...providerOptions[namespace],
249
+ ...(request.cacheIdentity === undefined ? {} : { promptCacheKey: request.cacheIdentity }),
250
+ ...(mode === 'openai-explicit' ? { promptCacheOptions: { mode: 'explicit', ttl: '30m' } } : {}),
251
+ ...(settings?.retention === undefined ? {} : { promptCacheRetention: settings.retention })
252
+ };
253
+ }
254
+ const minimum = settings?.minimumEligiblePrefixTokens ?? config.providerPromptCache?.minimumEligiblePrefixTokens ?? profile.value.cache.minimumEligiblePrefixTokens;
255
+ const maximum = Math.min(4, settings?.maximumBreakpoints ?? config.providerPromptCache?.maximumBreakpoints ?? profile.value.cache.maximumBreakpoints);
256
+ const toolsTokens = estimateTokens(canonicalize(definitions)).expected;
257
+ let cumulative = toolsTokens;
258
+ let blocks = definitions.length;
259
+ let prefix = digest({ routeDigest, tools: definitions, providerOptions, bodyOptions });
260
+ const entries = messages.map((message, ordinal) => {
261
+ cumulative += estimateTokens(canonicalize(message)).expected;
262
+ if (message.role === 'user' && Array.isArray(message.content))
263
+ cumulative += message.content.filter(part => part.type === 'image').length * imageTokenEstimate;
264
+ prefix = digest([prefix, digest(message)]);
265
+ blocks += typeof message.content === 'string' ? 1 : message.content.length;
266
+ return { ordinal, prefixTokens: cumulative, prefixDigest: prefix, eligible: cumulative >= minimum, blocks };
267
+ });
268
+ const { breakpoints, candidates, ttls } = selectCacheBreakpoints(logical, entries, {
269
+ mode, maximum, minimum, positions: settings?.positions ?? ['tool', 'system', 'message'], toolsTokens, hasTools: definitions.length > 0, ttl: settings?.ttl === '1h' ? '1h' : '5m',
270
+ supported: messages.map((message) => typeof message.content === 'string' ? message.content.length > 0
271
+ : mode === 'anthropic' ? message.content.length > 0 && !['reasoning', 'reasoning-file'].includes(message.content.at(-1)?.type ?? '') : message.role === 'tool'),
272
+ ...(record(previous?.preparation)?.cache_plan === undefined ? {} : { previous: record(previous?.preparation)?.cache_plan })
273
+ });
274
+ for (const ordinal of breakpoints) {
275
+ const ttl = ttls[ordinal];
276
+ if (ordinal === -1) {
277
+ const index = definitions.length - 1;
278
+ const definition = definitions[index];
279
+ definitions[index] = { ...definition, providerOptions: { anthropic: { cacheControl: { type: 'ephemeral', ttl: ttl ?? '5m' } } } };
280
+ continue;
281
+ }
282
+ const message = messages[ordinal];
283
+ const hint = mode === 'anthropic' ? { anthropic: { cacheControl: { type: 'ephemeral', ttl: ttl ?? settings?.ttl ?? '5m' } } } : { openai: { promptCacheBreakpoint: { mode: 'explicit' } } };
284
+ if (mode === 'openai-explicit' && message.role === 'tool') {
285
+ messages[ordinal] = { ...message, content: message.content.map((part, index) => {
286
+ if (index !== message.content.length - 1 || part.type !== 'tool-result' || part.output.type !== 'content')
287
+ return part;
288
+ const output = part.output;
289
+ return { ...part, output: { ...output, value: output.value.map((value, i) => i === output.value.length - 1 ? { ...value, providerOptions: hint } : value) } };
290
+ }) };
291
+ }
292
+ else if (message.role === 'system')
293
+ messages[ordinal] = { ...message, providerOptions: hint };
294
+ else if (typeof message.content === 'string')
295
+ messages[ordinal] = { ...message, content: [{ type: 'text', text: message.content, providerOptions: hint }] };
296
+ else
297
+ messages[ordinal] = { ...message, content: message.content.map((part, index) => {
298
+ if (index !== message.content.length - 1 || part.type === 'reasoning' || part.type === 'reasoning-file')
299
+ return part;
300
+ const options = 'providerOptions' in part ? part.providerOptions : undefined;
301
+ return { ...part, providerOptions: { ...options, [namespace]: { ...options?.[namespace], ...hint[namespace] } } };
302
+ }) };
303
+ }
304
+ // This Anthropic SDK removes schemas for `none`. Keep the cacheable schema prefix;
305
+ // the Compactor consumer rejects every tool event and never executes one.
306
+ const toolChoice = request.toolChoice === 'none' && provider.adapter !== '@ai-sdk/anthropic' ? 'none' : 'auto';
307
+ const fingerprint = digest({ model: model.wireModel ?? modelId, adapter: provider.adapter, messages: messages, tools: definitions, providerOptions, bodyOptions, toolChoice, maxOutputTokens: request.maxOutputTokens ?? null });
308
+ return {
309
+ version: 1, model: request.model, wireModel: model.wireModel ?? modelId, adapter: provider.adapter, routeDigest,
310
+ baseline, messages, tools: definitions, providerOptions, bodyOptions, toolChoice, reasoningEffort,
311
+ maxOutputTokens: request.maxOutputTokens ?? null,
312
+ cachePlan: { status: mode === 'off' ? 'disabled' : 'planned', mode, fingerprintVersion: 2, providerVisibleFingerprint: fingerprint,
313
+ hintsApplied: breakpoints.length > 0, breakpoints, candidates, ttls, entries, cacheIdentity: request.cacheIdentity ?? null,
314
+ model: request.model, routeDigest, systemDigest: digest(baseline.messages), toolsDigest: digest(baseline.tools), toolsTokens,
315
+ requestOptionsDigest: digest({ providerOptions, bodyOptions }), baselineRestored: reuseBaseline,
316
+ historyIds: logical.map((message) => message.contextItemId ?? ''), historyKinds: logical.map((message) => typeof message.metadata?.purpose === 'string' ? message.metadata.purpose : message.role),
317
+ snippedIds: logical.filter((message) => message.metadata?.snipped === true).map((message) => message.contextItemId ?? '') }
318
+ };
319
+ }
320
+ function reasoningBody(adapter, effort) {
321
+ if (effort === null)
322
+ return {};
129
323
  if (adapter === '@ai-sdk/openai')
130
- return { openai: { reasoningEffort: effort } };
324
+ return { reasoning: { effort } };
131
325
  if (adapter === '@ai-sdk/anthropic')
132
- return { anthropic: { effort } };
133
- return { [providerId]: { reasoningEffort: effort } };
326
+ return { output_config: { effort } };
327
+ return { reasoning_effort: effort };
134
328
  }
135
- function createModel(providerId, modelId, config) {
329
+ function createModel(providerId, modelId, config, bodyOptions, report) {
136
330
  const apiKey = resolveSecret(config.options.apiKey);
331
+ // Effort is user-owned. SDK enums must not reject or transform it before the
332
+ // endpoint sees it. Preserve other SDK-generated fields, headers and cancellation.
333
+ const transport = { fetch: (async (url, init) => {
334
+ const body = JSON.parse(init?.body);
335
+ for (const [key, value] of Object.entries(bodyOptions)) {
336
+ const fields = record(value);
337
+ body[key] = fields === undefined ? value : { ...record(body[key]), ...fields };
338
+ }
339
+ const serialized = JSON.stringify(body);
340
+ const destination = new URL(url instanceof Request ? url.url : url);
341
+ const transportId = randomUUID();
342
+ const base = { transport_id: transportId, destination_origin: destination.origin,
343
+ destination_path_sha256: sha256(destination.pathname), body_sha256: sha256(serialized), body_bytes: Buffer.byteLength(serialized) };
344
+ report?.({ ...base, event_type: 'model_send_started' });
345
+ let response;
346
+ try {
347
+ response = await fetch(url, { ...init, body: serialized, redirect: 'error' });
348
+ }
349
+ catch (error) {
350
+ report?.({ ...base, event_type: 'model_send_failed', outcome: 'unknown' });
351
+ throw error;
352
+ }
353
+ report?.({ ...base, event_type: 'model_response_headers', status: response.status,
354
+ outcome: 'headers_received_stream_not_confirmed' });
355
+ return response;
356
+ }) };
137
357
  if (config.adapter === '@ai-sdk/openai') {
138
- const provider = createOpenAI({ ...(apiKey === undefined ? {} : { apiKey }), ...(config.options.baseURL === undefined ? {} : { baseURL: config.options.baseURL }), ...(config.options.headers === undefined ? {} : { headers: config.options.headers }) });
139
- return { model: provider(modelId), programmaticTool: provider.tools.programmaticToolCalling() };
358
+ const provider = createOpenAI({ ...transport, ...(apiKey === undefined ? {} : { apiKey }), ...(config.options.baseURL === undefined ? {} : { baseURL: config.options.baseURL }), ...(config.options.headers === undefined ? {} : { headers: config.options.headers }) });
359
+ return { model: provider(modelId) };
140
360
  }
141
361
  if (config.adapter === '@ai-sdk/anthropic') {
142
- return { model: createAnthropic({ ...(apiKey === undefined ? {} : { apiKey }), ...(config.options.baseURL === undefined ? {} : { baseURL: config.options.baseURL }), ...(config.options.headers === undefined ? {} : { headers: config.options.headers }) })(modelId) };
362
+ return { model: createAnthropic({ ...transport, ...(apiKey === undefined ? {} : { apiKey }), ...(config.options.baseURL === undefined ? {} : { baseURL: config.options.baseURL }), ...(config.options.headers === undefined ? {} : { headers: config.options.headers }) })(modelId) };
143
363
  }
144
364
  if (config.options.baseURL === undefined)
145
365
  fail('CONFIG_INVALID', 503, `Provider ${providerId} requires options.baseURL.`);
146
- return { model: createOpenAICompatible({ name: providerId, baseURL: config.options.baseURL, ...(apiKey === undefined ? {} : { apiKey }), ...(config.options.headers === undefined ? {} : { headers: config.options.headers }) })(modelId) };
366
+ return { model: createOpenAICompatible({ ...transport, name: providerId, baseURL: config.options.baseURL, ...(apiKey === undefined ? {} : { apiKey }), ...(config.options.headers === undefined ? {} : { headers: config.options.headers }), convertUsage: compatibleUsage })(modelId) };
147
367
  }
148
- function tools(definitions, programmatic, providerTool, callers, ordinals) {
149
- const result = {};
150
- for (const definition of definitions) {
151
- if (definition.id === 'programmatic_tool_run')
152
- continue;
153
- const eligible = programmatic?.capabilityIds.includes(definition.id) === true;
154
- const descriptor = {
368
+ function compatibleUsage(usage) {
369
+ const number = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
370
+ const details = usage?.prompt_tokens_details;
371
+ const outputDetails = usage?.completion_tokens_details;
372
+ const total = number(usage?.prompt_tokens);
373
+ const read = number(usage?.prompt_cache_hit_tokens) ?? number(details?.cached_tokens);
374
+ const miss = number(usage?.prompt_cache_miss_tokens) ?? (total === undefined || read === undefined ? undefined : Math.max(0, total - read));
375
+ const output = number(usage?.completion_tokens);
376
+ const reasoning = number(outputDetails?.reasoning_tokens);
377
+ return { inputTokens: { total, noCache: miss, cacheRead: read, cacheWrite: undefined },
378
+ outputTokens: { total: output, text: output === undefined || reasoning === undefined ? undefined : Math.max(0, output - reasoning), reasoning } };
379
+ }
380
+ function tools(definitions) {
381
+ return Object.fromEntries(definitions.map((definition) => [definition.id, tool({
155
382
  description: definition.description,
383
+ ...(definition.providerOptions === undefined ? {} : { providerOptions: definition.providerOptions }),
156
384
  inputSchema: jsonSchema(definition.inputSchema),
157
- outputSchema: jsonSchema((definition.outputSchema ?? { type: 'object', additionalProperties: true }))
158
- };
159
- if (eligible) {
160
- result[definition.id] = tool({
161
- ...descriptor,
162
- providerOptions: { openai: { outputSchema: definition.outputSchema ?? { type: 'object', additionalProperties: true } } },
163
- execute: (input, options) => {
164
- const parentProviderCallId = callers.get(options.toolCallId);
165
- return programmatic.execute({
166
- capabilityId: definition.id,
167
- arguments: jsonValue(input),
168
- providerCallId: options.toolCallId,
169
- ordinal: ordinals.get(options.toolCallId) ?? 0,
170
- ...(parentProviderCallId === undefined ? {} : { parentProviderCallId })
171
- }, options.abortSignal);
172
- }
173
- });
174
- }
175
- else {
176
- result[definition.id] = tool(descriptor);
177
- }
178
- }
179
- if (programmatic !== undefined && providerTool !== undefined)
180
- result.programmatic_tool_run = providerTool;
181
- return result;
385
+ ...(definition.outputSchema === undefined ? {} : { outputSchema: jsonSchema(definition.outputSchema) })
386
+ })]));
182
387
  }
183
- function providerCaller(value) {
184
- if (typeof value !== 'object' || value === null || Array.isArray(value))
185
- return { programmatic: false, parentProviderCallId: null };
186
- const candidates = [value, ...Object.values(value).filter(isObject)];
187
- for (const candidate of candidates) {
188
- const caller = candidate.caller;
189
- if (!isObject(caller) || caller.type !== 'program')
190
- continue;
191
- const id = caller.callerId ?? caller.caller_id ?? caller.toolId ?? caller.tool_id;
192
- return { programmatic: true, parentProviderCallId: typeof id === 'string' && id.length > 0 ? id : null };
388
+ function aiMessage(message, route) {
389
+ if (message.role === 'assistant' && message.response?.route === route)
390
+ return { role: 'assistant', content: message.response.content };
391
+ if (message.role === 'user' && message.images?.length) {
392
+ return { role: 'user', content: [
393
+ { type: 'text', text: message.content },
394
+ ...message.images.map(image => ({ type: 'image', image: imageReference(image) }))
395
+ ] };
193
396
  }
194
- return { programmatic: false, parentProviderCallId: null };
195
- }
196
- function isObject(value) {
197
- return typeof value === 'object' && value !== null && !Array.isArray(value);
198
- }
199
- function aiMessage(message) {
200
397
  if (message.role === 'assistant' && message.toolCalls !== undefined) {
201
398
  return {
202
399
  role: 'assistant',
@@ -224,6 +421,34 @@ function aiMessage(message) {
224
421
  }
225
422
  return { role: message.role, content: message.content };
226
423
  }
424
+ function responseRoute(providerId, provider, modelId) {
425
+ return digest({ providerId, adapter: provider.adapter, baseURL: provider.options.baseURL ?? null, model: provider.models[modelId]?.wireModel ?? modelId });
426
+ }
427
+ function assertReasoningState(response, adapter, profile) {
428
+ // OpenAI may split one reasoning item into multiple summaries; only the final
429
+ // summary carries its ciphertext, and the SDK rejoins them by itemId on replay.
430
+ const encryptedItems = new Set(response.content.flatMap(part => {
431
+ if (part.type !== 'reasoning')
432
+ return [];
433
+ const metadata = part.providerOptions?.openai;
434
+ return typeof metadata?.reasoningEncryptedContent === 'string' && typeof metadata.itemId === 'string' ? [metadata.itemId] : [];
435
+ }));
436
+ for (const part of response.content) {
437
+ if (part.type !== 'reasoning' && part.type !== 'reasoning-file')
438
+ continue;
439
+ if (profile.value.semantics.reasoningStateRoundTrip === 'forbidden' || profile.value.semantics.thinkingContinuation === 'forbidden') {
440
+ fail('MODEL_REASONING_STATE_FORBIDDEN', 409, 'The Model Profile forbids replaying reasoning state.');
441
+ }
442
+ const metadata = part.providerOptions;
443
+ if (adapter === '@ai-sdk/openai-compatible' && profile.value.semantics.reasoningStateRoundTrip === 'optional')
444
+ continue;
445
+ if (adapter === '@ai-sdk/anthropic' && (typeof metadata?.anthropic?.signature === 'string' || typeof metadata?.anthropic?.redactedData === 'string'))
446
+ continue;
447
+ if (adapter === '@ai-sdk/openai' && (typeof metadata?.openai?.reasoningEncryptedContent === 'string' || (typeof metadata?.openai?.itemId === 'string' && encryptedItems.has(metadata.openai.itemId))))
448
+ continue;
449
+ fail('MODEL_REASONING_STATE_MISSING', 502, 'The Provider returned reasoning without replayable state.');
450
+ }
451
+ }
227
452
  function toolOutput(content) {
228
453
  try {
229
454
  const parsed = JSON.parse(content);
@@ -14,6 +14,7 @@ export declare class Observability {
14
14
  constructor(root: string, config: HarnessConfig['observability']);
15
15
  record(component: string, operation: string, outcome: string, durationMs: number, fields?: Readonly<Record<string, Json>>): void;
16
16
  increment(name: string, amount?: number): void;
17
+ status(): Readonly<Record<string, Json>>;
17
18
  logs(limit: number): readonly RuntimeRecord[];
18
19
  traces(limit: number): readonly RuntimeRecord[];
19
20
  agentOps(limit: number): readonly RuntimeRecord[];