@sayknow-cli/agent-core 0.3.6 → 0.3.8

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.
@@ -0,0 +1,430 @@
1
+ import type { AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream, Effort, ImageContent, Message, Model, SimpleStreamOptions, Static, streamSimple, TextContent, Tool, ToolChoice, ToolResultMessage, TSchema } from "@sayknow-cli/ai";
2
+ import type { AppendOnlyContextManager } from "./append-only-context";
3
+ import type { HarmonyAuditEvent } from "./harmony-leak";
4
+ import type { AgentRunCoverage, AgentRunSummary } from "./run-collector";
5
+ import type { AgentTelemetryConfig } from "./telemetry";
6
+ /** Stream function - can return sync or Promise for async config lookup */
7
+ export type StreamFn = (...args: Parameters<typeof streamSimple>) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
8
+ /**
9
+ * Configuration for the agent loop.
10
+ */
11
+ export interface AgentLoopConfig extends SimpleStreamOptions {
12
+ model: Model;
13
+ /**
14
+ * When to interrupt tool execution for steering messages.
15
+ * - "immediate" = check after each tool call (default)
16
+ * - "wait" = defer steering until the current turn completes
17
+ */
18
+ interruptMode?: "immediate" | "wait";
19
+ /**
20
+ * Optional session identifier forwarded to LLM providers.
21
+ * Used by providers that support session-based caching (e.g., OpenAI code provider).
22
+ */
23
+ sessionId?: string;
24
+ /**
25
+ * Optional provider-facing cache/session affinity identifier. When set, this
26
+ * is forwarded to providers as StreamOptions.sessionId while `sessionId`
27
+ * remains the logical agent conversation id for telemetry/metadata.
28
+ */
29
+ providerSessionId?: string;
30
+ /**
31
+ * Optional resolver called per LLM request to produce request metadata.
32
+ * When set, the agent loop evaluates it **after** `getApiKey` resolves the
33
+ * session-sticky credential, ensuring the metadata's `account_uuid` reflects
34
+ * the credential actually used for the request (not the credential that was
35
+ * current when `AgentLoopConfig` was first constructed). Overrides the static
36
+ * `metadata` field when present.
37
+ */
38
+ metadataResolver?: (provider: string) => Record<string, unknown> | undefined;
39
+ /**
40
+ * Converts AgentMessage[] to LLM-compatible Message[] before each LLM call.
41
+ *
42
+ * Each AgentMessage must be converted to a UserMessage, AssistantMessage, or ToolResultMessage
43
+ * that the LLM can understand. AgentMessages that cannot be converted (e.g., UI-only notifications,
44
+ * status messages) should be filtered out.
45
+ *
46
+ * @example
47
+ * ```typescript
48
+ * convertToLlm: (messages) => messages.flatMap(m => {
49
+ * if (m.role === "custom") {
50
+ * // Convert custom message to user message
51
+ * return [{ role: "user", content: m.content, timestamp: m.timestamp }];
52
+ * }
53
+ * if (m.role === "notification") {
54
+ * // Filter out UI-only messages
55
+ * return [];
56
+ * }
57
+ * // Pass through standard LLM messages
58
+ * return [m];
59
+ * })
60
+ * ```
61
+ */
62
+ convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
63
+ /**
64
+ * Optional transform applied to the context before `convertToLlm`.
65
+ *
66
+ * Use this for operations that work at the AgentMessage level:
67
+ * - Context window management (pruning old messages)
68
+ * - Injecting context from external sources
69
+ *
70
+ * @example
71
+ * ```typescript
72
+ * transformContext: async (messages) => {
73
+ * if (estimateTokens(messages) > MAX_TOKENS) {
74
+ * return pruneOldMessages(messages);
75
+ * }
76
+ * return messages;
77
+ * }
78
+ * ```
79
+ */
80
+ transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
81
+ /**
82
+ * Resolves an API key dynamically for each LLM call.
83
+ *
84
+ * Useful for short-lived OAuth tokens (e.g., GitHub Copilot) that may expire
85
+ * during long-running tool execution phases.
86
+ */
87
+ getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
88
+ /** Returns the credential type selected by the most recent getApiKey call for this session/provider. */
89
+ getAuthCredentialType?: (provider: string) => "api_key" | "oauth" | undefined;
90
+ /**
91
+ * Returns steering messages to inject into the conversation mid-run.
92
+ *
93
+ * Called after each tool execution to check for user interruptions unless interruptMode is "wait".
94
+ * If messages are returned, remaining tool calls are skipped and
95
+ * these messages are added to the context before the next LLM call.
96
+ */
97
+ getSteeringMessages?: () => Promise<AgentMessage[]>;
98
+ /**
99
+ * Returns follow-up messages to process after the agent would otherwise stop.
100
+ *
101
+ * Called when the agent has no more tool calls and no steering messages.
102
+ * If messages are returned, they're added to the context and the agent
103
+ * continues with another turn.
104
+ */
105
+ getFollowUpMessages?: () => Promise<AgentMessage[]>;
106
+ /**
107
+ * Cooperative pause checkpoint evaluated at safe loop boundaries.
108
+ *
109
+ * Called after completed tool execution has been emitted and before the loop
110
+ * polls steering/follow-up queues or schedules another assistant response.
111
+ * Returning true ends the current loop with `agent_end.stopReason === "paused"`
112
+ * without aborting any in-flight model or tool work.
113
+ */
114
+ shouldPause?: () => boolean;
115
+ /**
116
+ * Hook fired right before the loop would exit.
117
+ *
118
+ * Called when the agent has no more tool calls and no steering messages,
119
+ * immediately before polling follow-up messages.
120
+ */
121
+ onBeforeYield?: () => Promise<void> | void;
122
+ /**
123
+ * Provides tool execution context, resolved per tool call.
124
+ * Use for late-bound UI or session state access.
125
+ */
126
+ getToolContext?: (toolCall?: ToolCallContext) => AgentToolContext | undefined;
127
+ /**
128
+ * Refreshes prompt/tool context from live session state before each model call.
129
+ * Use this when tool availability or the system prompt can change mid-turn.
130
+ */
131
+ syncContextBeforeModelCall?: (context: AgentContext) => void | Promise<void>;
132
+ /**
133
+ * Optional transform applied to tool call arguments before execution.
134
+ * Use for deobfuscating secrets or rewriting arguments.
135
+ */
136
+ transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
137
+ /**
138
+ * Enable intent tracing for tool calls.
139
+ * When enabled, the harness injects a `string` field into tool schemas sent to the model,
140
+ * then strips from arguments before executing tools.
141
+ */
142
+ intentTracing?: boolean;
143
+ /**
144
+ * Append-only context mode — stabilizes system prompt + tool spec bytes
145
+ * across turns so provider prefix caches hit at maximum rate.
146
+ *
147
+ * When set, the loop reads messages from the append-only log (stable
148
+ * byte prefix) and caches system prompt + tools. Tools exclude per-turn
149
+ * `_i` intent fields.
150
+ */
151
+ appendOnlyContext?: AppendOnlyContextManager;
152
+ /**
153
+ * Inspect assistant streaming events before they are published to the outer agent event stream.
154
+ * Callers may abort synchronously to stop consuming buffered provider events.
155
+ */
156
+ onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
157
+ /** Called for non-content tool-choice incapability stream events. */
158
+ onToolChoiceIncapability?: (event: Extract<AssistantMessageEvent, {
159
+ type: "toolChoiceIncapability";
160
+ }>) => void;
161
+ /**
162
+ * Called when GPT-5 Harmony protocol leakage is detected and mitigated.
163
+ */
164
+ onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
165
+ /**
166
+ * Dynamic tool choice override, resolved per LLM call.
167
+ * When set and returns a value, overrides the static `toolChoice`.
168
+ */
169
+ getToolChoice?: () => ToolChoice | undefined;
170
+ /**
171
+ * Dynamic reasoning effort override, resolved per LLM call.
172
+ * When set and returns a value, overrides the static `reasoning` captured
173
+ * at run-loop start. Use this so mid-run thinking-level changes apply on
174
+ * the next model call instead of waiting for the next prompt.
175
+ */
176
+ getReasoning?: () => Effort | undefined;
177
+ /**
178
+ * Called after a tool call has been validated and is about to execute.
179
+ *
180
+ * Return `{ block: true }` to prevent execution. The loop emits an error tool
181
+ * result instead (using `reason` as the error text, or a default if omitted).
182
+ *
183
+ * Mutating `context.args` in place changes the arguments passed to `tool.execute`
184
+ * — the loop does **not** re-validate after this hook runs.
185
+ *
186
+ * The hook receives the tool abort signal (`signal`) and is responsible for
187
+ * honoring it. Throwing surfaces as a tool-error result and does not abort the
188
+ * rest of the batch.
189
+ */
190
+ beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined> | BeforeToolCallResult | undefined;
191
+ /**
192
+ * Called after a tool finishes executing, before `tool_execution_end` and the
193
+ * tool-result message are emitted.
194
+ *
195
+ * Return an `AfterToolCallResult` to override individual fields of the executed
196
+ * tool result. Omitted fields keep their original values; there is no deep merge.
197
+ *
198
+ * Throwing surfaces as a tool-error result and does not abort the rest of the batch.
199
+ */
200
+ afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined> | AfterToolCallResult | undefined;
201
+ /**
202
+ * Opt-in OpenTelemetry instrumentation. Passing `{}` enables the loop's
203
+ * GenAI-semantic-convention spans (`invoke_agent`, `chat`, `execute_tool`)
204
+ * using the global tracer provider. Leaving this field undefined disables
205
+ * the instrumentation entirely — the loop performs zero tracer lookups.
206
+ *
207
+ * See {@link AgentTelemetryConfig} for the full surface (hooks, content
208
+ * capture, cost estimator, agent identity).
209
+ */
210
+ telemetry?: AgentTelemetryConfig;
211
+ }
212
+ /**
213
+ * Batch/sequencing metadata for the tool call currently being processed.
214
+ */
215
+ export interface ToolCallContext {
216
+ batchId: string;
217
+ index: number;
218
+ total: number;
219
+ toolCalls: Array<{
220
+ id: string;
221
+ name: string;
222
+ }>;
223
+ }
224
+ /** A single tool-call content block emitted by an assistant message. */
225
+ export type AgentToolCall = Extract<AssistantMessage["content"][number], {
226
+ type: "toolCall";
227
+ }>;
228
+ /**
229
+ * Result returned from `beforeToolCall`.
230
+ *
231
+ * Set `block: true` to prevent the tool from executing. The loop emits an error tool
232
+ * result instead, using `reason` as the error text (or a default if omitted).
233
+ *
234
+ * Mutating the `args` reference passed in `BeforeToolCallContext` is supported and
235
+ * survives into execution — the loop does **not** re-validate after this hook runs.
236
+ */
237
+ export interface BeforeToolCallResult {
238
+ block?: boolean;
239
+ reason?: string;
240
+ }
241
+ /**
242
+ * Partial override returned from `afterToolCall`.
243
+ *
244
+ * Merge semantics are field-by-field; omitted fields keep the executed values.
245
+ * No deep merge is performed.
246
+ */
247
+ export interface AfterToolCallResult {
248
+ /** If provided, replaces the tool result content array in full. */
249
+ content?: (TextContent | ImageContent)[];
250
+ /** If provided, replaces the tool result details payload in full. */
251
+ details?: unknown;
252
+ /** If provided, replaces the error flag carried with the tool result. */
253
+ isError?: boolean;
254
+ }
255
+ /** Context passed to `beforeToolCall`. */
256
+ export interface BeforeToolCallContext {
257
+ /** The assistant message that requested the tool call. */
258
+ assistantMessage: AssistantMessage;
259
+ /** The raw tool call block from `assistantMessage.content`. */
260
+ toolCall: AgentToolCall;
261
+ /**
262
+ * Validated tool arguments. The same reference is forwarded to `tool.execute`
263
+ * (after any `transformToolCallArguments` pass), so in-place mutations stick.
264
+ */
265
+ args: Record<string, unknown>;
266
+ /** Current agent context at the time the tool call is prepared. */
267
+ context: AgentContext;
268
+ }
269
+ /** Context passed to `afterToolCall`. */
270
+ export interface AfterToolCallContext {
271
+ /** The assistant message that requested the tool call. */
272
+ assistantMessage: AssistantMessage;
273
+ /** The raw tool call block from `assistantMessage.content`. */
274
+ toolCall: AgentToolCall;
275
+ /** Validated tool arguments used for execution (post `beforeToolCall` mutations). */
276
+ args: Record<string, unknown>;
277
+ /** The executed tool result before any `afterToolCall` overrides are applied. */
278
+ result: AgentToolResult<any>;
279
+ /** Whether the executed tool result is currently treated as an error. */
280
+ isError: boolean;
281
+ /** Current agent context at the time the tool call is finalized. */
282
+ context: AgentContext;
283
+ }
284
+ /**
285
+ * Extensible interface for custom app messages.
286
+ * Apps can extend via declaration merging:
287
+ *
288
+ * @example
289
+ * ```typescript
290
+ * declare module "@sayknow-cli/agent" {
291
+ * interface CustomAgentMessages {
292
+ * artifact: ArtifactMessage;
293
+ * notification: NotificationMessage;
294
+ * }
295
+ * }
296
+ * ```
297
+ */
298
+ export interface CustomAgentMessages {
299
+ }
300
+ /**
301
+ * AgentMessage: Union of LLM messages + custom messages.
302
+ * This abstraction allows apps to add custom message types while maintaining
303
+ * type safety and compatibility with the base LLM messages.
304
+ */
305
+ export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];
306
+ /**
307
+ * Agent state containing all configuration and conversation data.
308
+ */
309
+ export interface AgentState {
310
+ systemPrompt: string[];
311
+ model: Model;
312
+ thinkingLevel?: Effort;
313
+ tools: AgentTool<any>[];
314
+ messages: AgentMessage[];
315
+ isStreaming: boolean;
316
+ streamMessage: AgentMessage | null;
317
+ pendingToolCalls: Set<string>;
318
+ error?: string;
319
+ }
320
+ export interface AgentToolResult<T = any, _TInput = unknown> {
321
+ content: (TextContent | ImageContent)[];
322
+ details?: T;
323
+ isError?: boolean;
324
+ }
325
+ export type AgentToolUpdateCallback<T = any, TInput = unknown> = (partialResult: AgentToolResult<T, TInput>) => void;
326
+ /** Options passed to renderResult */
327
+ export interface RenderResultOptions {
328
+ /** Whether the result view is expanded */
329
+ expanded: boolean;
330
+ /** Whether this is a partial/streaming result */
331
+ isPartial: boolean;
332
+ /** Current spinner frame index for animated elements (optional) */
333
+ spinnerFrame?: number;
334
+ }
335
+ /**
336
+ * Context passed to tool execution.
337
+ * Apps can extend via declaration merging.
338
+ */
339
+ export interface AgentToolContext {
340
+ }
341
+ export type AgentToolExecFn<TParameters extends TSchema = TSchema, TDetails = any, TTheme = unknown> = (this: AgentTool<TParameters, TDetails, TTheme>, toolCallId: string, params: Static<TParameters>, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback<TDetails, TParameters>, context?: AgentToolContext) => Promise<AgentToolResult<TDetails, TParameters>>;
342
+ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any, TTheme = unknown> extends Tool<TParameters> {
343
+ label: string;
344
+ /** If true, tool is excluded unless explicitly listed in --tools or agent's tools field */
345
+ hidden?: boolean;
346
+ /** If true, tool can stage a pending action that requires explicit resolution via the resolve tool. */
347
+ deferrable?: boolean;
348
+ /** Built-in tool loading behavior. "essential" loads initially; "discoverable" can be activated by tool search. */
349
+ loadMode?: "essential" | "discoverable";
350
+ /** Short one-line summary used for tool discovery indexes. */
351
+ summary?: string;
352
+ /** If true, tool execution ignores abort signals (runs to completion) */
353
+ nonAbortable?: boolean;
354
+ /**
355
+ * Concurrency mode for tool scheduling when multiple calls are in one turn.
356
+ * - "shared": can run alongside other shared tools (default)
357
+ * - "exclusive": runs alone; other tools wait until it finishes
358
+ */
359
+ concurrency?: "shared" | "exclusive";
360
+ /** If true, argument validation errors are non-fatal: raw args are passed to execute() instead of returning an error to the LLM. */
361
+ lenientArgValidation?: boolean;
362
+ /**
363
+ * Controls how the INTENT_FIELD (`_i`) is handled for this tool.
364
+ * - `"require"` (default): `_i` is injected and required in the parameter schema.
365
+ * - `"optional"`: `_i` is injected as an optional/nullable field.
366
+ * - `"omit"`: `_i` is NOT injected. Use for tools where intent is obvious (yield, resolve, todo_write, …).
367
+ * - function: `_i` is NOT injected; intent is derived dynamically from (potentially partial / streaming) args.
368
+ */
369
+ intent?: "omit" | "optional" | "require" | ((args: Partial<Static<TParameters>>) => string | undefined);
370
+ /** The main execution callback for this tool. */
371
+ execute: AgentToolExecFn<TParameters, TDetails, TTheme>;
372
+ /** Optional custom rendering for tool call display (returns UI component) */
373
+ renderCall?: (args: Static<TParameters>, options: RenderResultOptions, theme: TTheme) => unknown;
374
+ /** Optional custom rendering for tool result display (returns UI component) */
375
+ renderResult?: (result: AgentToolResult<TDetails, TParameters>, options: RenderResultOptions, theme: TTheme) => unknown;
376
+ }
377
+ export interface AgentContext {
378
+ systemPrompt: string[];
379
+ messages: AgentMessage[];
380
+ tools?: AgentTool<any>[];
381
+ }
382
+ /**
383
+ * Events emitted by the Agent for UI updates.
384
+ * These events provide fine-grained lifecycle information for messages, turns, and tool executions.
385
+ */
386
+ export type AgentEvent = {
387
+ type: "agent_start";
388
+ } | {
389
+ type: "agent_end";
390
+ messages: AgentMessage[];
391
+ /** Indicates whether the loop ended normally or suspended at a pause checkpoint. */
392
+ stopReason?: "completed" | "paused";
393
+ /** Present iff `AgentTelemetryConfig` was supplied on this run. */
394
+ telemetry?: AgentRunSummary;
395
+ coverage?: AgentRunCoverage;
396
+ } | {
397
+ type: "turn_start";
398
+ } | {
399
+ type: "turn_end";
400
+ message: AgentMessage;
401
+ toolResults: ToolResultMessage[];
402
+ } | {
403
+ type: "message_start";
404
+ message: AgentMessage;
405
+ } | {
406
+ type: "message_update";
407
+ message: AgentMessage;
408
+ assistantMessageEvent: AssistantMessageEvent;
409
+ } | {
410
+ type: "message_end";
411
+ message: AgentMessage;
412
+ } | {
413
+ type: "tool_execution_start";
414
+ toolCallId: string;
415
+ toolName: string;
416
+ args: any;
417
+ intent?: string;
418
+ } | {
419
+ type: "tool_execution_update";
420
+ toolCallId: string;
421
+ toolName: string;
422
+ args: any;
423
+ partialResult: any;
424
+ } | {
425
+ type: "tool_execution_end";
426
+ toolCallId: string;
427
+ toolName: string;
428
+ result: any;
429
+ isError?: boolean;
430
+ };
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sayknow-cli/agent-core",
4
- "version": "0.3.6",
4
+ "version": "0.3.8",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
- "homepage": "https://github.com/jaybeyond/Sayknow_CLI",
6
+ "homepage": "https://sayknow-cli.com",
7
7
  "author": "jaybeyond",
8
8
  "contributors": [
9
9
  "Mario Zechner"
@@ -25,7 +25,7 @@
25
25
  "state-management"
26
26
  ],
27
27
  "main": "./src/index.ts",
28
- "types": "./src/index.ts",
28
+ "types": "./dist/types/index.d.ts",
29
29
  "scripts": {
30
30
  "check": "biome check . && bun run check:types",
31
31
  "check:types": "tsgo -p tsconfig.json --noEmit",
@@ -35,9 +35,9 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@sayknow-cli/ai": "0.3.6",
39
- "@sayknow-cli/natives": "0.3.6",
40
- "@sayknow-cli/utils": "0.3.6",
38
+ "@sayknow-cli/ai": "0.3.8",
39
+ "@sayknow-cli/natives": "0.3.8",
40
+ "@sayknow-cli/utils": "0.3.8",
41
41
  "@opentelemetry/api": "^1.9.0"
42
42
  },
43
43
  "devDependencies": {
@@ -51,23 +51,24 @@
51
51
  "files": [
52
52
  "src",
53
53
  "README.md",
54
- "CHANGELOG.md"
54
+ "CHANGELOG.md",
55
+ "dist/types"
55
56
  ],
56
57
  "exports": {
57
58
  ".": {
58
- "types": "./src/index.ts",
59
+ "types": "./dist/types/index.d.ts",
59
60
  "import": "./src/index.ts"
60
61
  },
61
62
  "./compaction": {
62
- "types": "./src/compaction.ts",
63
+ "types": "./dist/types/compaction.d.ts",
63
64
  "import": "./src/compaction.ts"
64
65
  },
65
66
  "./compaction/*": {
66
- "types": "./src/compaction/*.ts",
67
+ "types": "./dist/types/compaction/*.d.ts",
67
68
  "import": "./src/compaction/*.ts"
68
69
  },
69
70
  "./*": {
70
- "types": "./src/*.ts",
71
+ "types": "./dist/types/*.d.ts",
71
72
  "import": "./src/*.ts"
72
73
  }
73
74
  }
package/src/agent-loop.ts CHANGED
@@ -342,9 +342,136 @@ export function normalizeMessagesForProvider(
342
342
  return changed ? normalized : messages;
343
343
  }
344
344
 
345
+ interface ConvertedContextCacheEntry {
346
+ messageHashes: string[];
347
+ modelKey: string;
348
+ toolKey: string;
349
+ intentTracing: boolean;
350
+ convertToLlm: AgentLoopConfig["convertToLlm"];
351
+ transformContext: AgentLoopConfig["transformContext"];
352
+ llmMessages: Context["messages"];
353
+ normalizedMessages: Context["messages"];
354
+ }
355
+
356
+ const convertedContextCache = new WeakMap<AgentLoopConfig, ConvertedContextCacheEntry>();
357
+
358
+ function stableCacheString(value: unknown): string | undefined {
359
+ try {
360
+ return JSON.stringify(value, (_key, item) =>
361
+ typeof item === "function" ? `[Function:${item.name || "anonymous"}]` : item,
362
+ );
363
+ } catch {
364
+ return undefined;
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Hash a message by full content serialization.
370
+ *
371
+ * Deliberately NOT memoized by object identity: callers mutate messages in
372
+ * place (compaction rewrites, obfuscation, abort markers) and the cache's
373
+ * correctness contract requires detecting those mutations. The per-turn
374
+ * serialization cost is the price of that contract; the win is skipping
375
+ * convertToLlm + normalize on stable contexts, which dominates for
376
+ * image-heavy histories.
377
+ */
378
+ function hashMessageContent(message: AgentMessage): string | undefined {
379
+ return stableCacheString(message);
380
+ }
381
+
382
+ function buildConvertedContextCacheKeys(
383
+ messages: AgentMessage[],
384
+ context: AgentContext,
385
+ config: AgentLoopConfig,
386
+ ): Pick<ConvertedContextCacheEntry, "messageHashes" | "modelKey" | "toolKey" | "intentTracing"> | undefined {
387
+ const intentTracing = !!config.intentTracing;
388
+ const messageHashes = messages.map(hashMessageContent);
389
+ const modelKey = stableCacheString(config.model);
390
+ const toolKey = stableCacheString(normalizeTools(context.tools, intentTracing) ?? []);
391
+ if (messageHashes.some(hash => hash === undefined) || modelKey === undefined || toolKey === undefined) {
392
+ return undefined;
393
+ }
394
+ return {
395
+ messageHashes: messageHashes as string[],
396
+ modelKey,
397
+ toolKey,
398
+ intentTracing,
399
+ };
400
+ }
401
+
402
+ function findStablePrefixLength(previous: string[], next: string[]): number {
403
+ const max = Math.min(previous.length, next.length);
404
+ let index = 0;
405
+ while (index < max && previous[index] === next[index]) index++;
406
+ return index;
407
+ }
408
+
409
+ async function convertAndNormalizeMessages(
410
+ messages: AgentMessage[],
411
+ context: AgentContext,
412
+ config: AgentLoopConfig,
413
+ ): Promise<Context["messages"]> {
414
+ const keys = buildConvertedContextCacheKeys(messages, context, config);
415
+ if (!keys) {
416
+ return normalizeMessagesForProvider(await config.convertToLlm(messages), config.model);
417
+ }
418
+ const previous = convertedContextCache.get(config);
419
+ const canReuse =
420
+ previous &&
421
+ previous.convertToLlm === config.convertToLlm &&
422
+ previous.transformContext === config.transformContext &&
423
+ previous.modelKey === keys.modelKey &&
424
+ previous.toolKey === keys.toolKey &&
425
+ previous.intentTracing === keys.intentTracing;
426
+
427
+ if (canReuse) {
428
+ const stablePrefixLength = findStablePrefixLength(previous.messageHashes, keys.messageHashes);
429
+ if (stablePrefixLength === keys.messageHashes.length && stablePrefixLength === previous.messageHashes.length) {
430
+ return previous.normalizedMessages;
431
+ }
432
+ // Append-only fast path: convert only the new suffix and concatenate.
433
+ // CONTRACT: `convertToLlm` must be per-message (each output message
434
+ // derived solely from its input message). The bundled converters
435
+ // satisfy this — they map/filter message-by-message. A converter that
436
+ // merges adjacent messages or pairs across the suffix boundary would
437
+ // diverge from a full rebuild; such converters must not be combined
438
+ // with appendOnlyContext. Covered by the suffix-equivalence test in
439
+ // agent-loop-context-cache.test.ts.
440
+ if (
441
+ config.appendOnlyContext &&
442
+ stablePrefixLength === previous.messageHashes.length &&
443
+ keys.messageHashes.length > previous.messageHashes.length
444
+ ) {
445
+ const suffix = messages.slice(stablePrefixLength);
446
+ const convertedSuffix = await config.convertToLlm(suffix);
447
+ const llmMessages = [...previous.llmMessages, ...convertedSuffix];
448
+ const normalizedMessages = normalizeMessagesForProvider(llmMessages, config.model);
449
+ convertedContextCache.set(config, {
450
+ ...keys,
451
+ convertToLlm: config.convertToLlm,
452
+ transformContext: config.transformContext,
453
+ llmMessages,
454
+ normalizedMessages,
455
+ });
456
+ return normalizedMessages;
457
+ }
458
+ }
459
+
460
+ const llmMessages = await config.convertToLlm(messages);
461
+ const normalizedMessages = normalizeMessagesForProvider(llmMessages, config.model);
462
+ convertedContextCache.set(config, {
463
+ ...keys,
464
+ convertToLlm: config.convertToLlm,
465
+ transformContext: config.transformContext,
466
+ llmMessages,
467
+ normalizedMessages,
468
+ });
469
+ return normalizedMessages;
470
+ }
471
+
345
472
  export const INTENT_FIELD = "_i";
346
473
 
347
- function injectIntentIntoSchema(schema: unknown, mode: "require" | "optional" = "require"): unknown {
474
+ function injectIntentIntoSchema(schema: unknown, mode: "require" | "optional" = "optional"): unknown {
348
475
  if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema;
349
476
  const schemaRecord = schema as Record<string, unknown>;
350
477
  const propertiesValue = schemaRecord.properties;
@@ -400,7 +527,7 @@ export function normalizeTools(tools: AgentContext["tools"], injectIntent: boole
400
527
  function resolveIntentMode(intent: AgentTool["intent"]): "require" | "optional" | "omit" {
401
528
  if (typeof intent === "function") return "omit";
402
529
  if (intent === "optional" || intent === "omit") return intent;
403
- return "require";
530
+ return intent === "require" ? "require" : "optional";
404
531
  }
405
532
 
406
533
  function extractIntent(args: Record<string, unknown>): { intent?: string; strippedArgs: Record<string, unknown> } {
@@ -711,9 +838,9 @@ async function streamAssistantResponse(
711
838
  messages = await config.transformContext(messages, signal);
712
839
  }
713
840
 
714
- // Convert to LLM-compatible messages (AgentMessage[] → Message[])
715
- const llmMessages = await config.convertToLlm(messages);
716
- const normalizedMessages = normalizeMessagesForProvider(llmMessages, config.model);
841
+ // Convert to LLM-compatible messages (AgentMessage[] → Message[]) and normalize at the LLM boundary.
842
+ // Cache hits are keyed by provider-visible content hashes, never message object identity.
843
+ const normalizedMessages = await convertAndNormalizeMessages(messages, context, config);
717
844
 
718
845
  // Build LLM context — append-only mode caches system prompt + tools
719
846
  // AND keeps an append-only message log so prior-turn bytes are stable.