@oh-my-pi/pi-agent-core 18.3.0 → 18.3.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.
package/src/agent-loop.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  type ToolResultProviderMetadata,
23
23
  type TSchema,
24
24
  toolWireSchema,
25
+ type UserMessage,
25
26
  validateToolArguments,
26
27
  } from "@oh-my-pi/pi-ai";
27
28
  import {
@@ -38,6 +39,7 @@ import {
38
39
  getStreamingPartialJson,
39
40
  kCursorExecResolved,
40
41
  } from "@oh-my-pi/pi-ai/utils/block-symbols";
42
+ import { schemaDefinesProperty } from "@oh-my-pi/pi-ai/utils/schema/json-schema-validator";
41
43
  import { stamp } from "@oh-my-pi/pi-ai/utils/schema/stamps";
42
44
  import {
43
45
  createHarmonyAuditEvent,
@@ -51,6 +53,7 @@ import {
51
53
  } from "@oh-my-pi/pi-ai/utils/harmony-leak";
52
54
  import { logger, sanitizeText, structuredCloneJSON } from "@oh-my-pi/pi-utils";
53
55
  import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
56
+ import { LiveSteeringChannel } from "./live-steering";
54
57
  import { agentPauseGate } from "./pause";
55
58
  import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
56
59
  import { SpeculativeOperationCoordinator } from "./speculative-execution";
@@ -70,6 +73,7 @@ import {
70
73
  startExecuteToolSpan,
71
74
  startInvokeAgentSpan,
72
75
  } from "./telemetry";
76
+ import { createAdditionalContextMessage, isNonBlankContext, joinAdditionalContext } from "./tool-context";
73
77
  import type {
74
78
  AgentContext,
75
79
  AgentEvent,
@@ -748,6 +752,7 @@ async function emitTurnEnd(
748
752
  await config.onTurnEnd?.(currentContext.messages, terminalYield ? undefined : signal, {
749
753
  message,
750
754
  toolResults,
755
+ additionalMessages: [],
751
756
  willContinue: false,
752
757
  ...context,
753
758
  });
@@ -1018,6 +1023,13 @@ function resolveIntentMode(intent: AgentTool["intent"]): "require" | "optional"
1018
1023
  return "require";
1019
1024
  }
1020
1025
 
1026
+ /**
1027
+ * Longest `i` value accepted as an intent. The injected field is described as
1028
+ * a "concise intent" (INTENT_FIELD_DESCRIPTION); anything past this is a tool
1029
+ * payload the model put in the wrong field, not a label.
1030
+ */
1031
+ const MAX_INTENT_LENGTH = 200;
1032
+
1021
1033
  function extractIntent(args: Record<string, unknown>): { intent?: string; strippedArgs: Record<string, unknown> } {
1022
1034
  const { [INTENT_FIELD]: intent, ...strippedArgs } = args;
1023
1035
  if (typeof intent !== "string") {
@@ -1096,6 +1108,26 @@ function emitInputMessages(stream: EventStream<AgentEvent, AgentMessage[]>, mess
1096
1108
  }
1097
1109
  }
1098
1110
 
1111
+ /**
1112
+ * Append passive tool-call context after its results as a developer message.
1113
+ * Returns the injected message for turn-end bookkeeping, or undefined when
1114
+ * there is nothing to inject. Shared by the normal tool-call path and the
1115
+ * resume-tail replay so replayed calls deliver context identically.
1116
+ */
1117
+ function injectExecutionAdditionalContext(
1118
+ currentContext: AgentContext,
1119
+ newMessages: AgentMessage[],
1120
+ stream: EventStream<AgentEvent, AgentMessage[]>,
1121
+ additionalContext: string | undefined,
1122
+ ): AgentMessage | undefined {
1123
+ if (additionalContext === undefined) return undefined;
1124
+ const contextMessage = createAdditionalContextMessage(additionalContext);
1125
+ currentContext.messages.push(contextMessage);
1126
+ newMessages.push(contextMessage);
1127
+ emitInputMessages(stream, [contextMessage]);
1128
+ return contextMessage;
1129
+ }
1130
+
1099
1131
  /**
1100
1132
  * Resolve aside entries at the moment the loop is about to inject them. Each entry
1101
1133
  * is either a ready {@link AgentMessage} or a sync thunk evaluated here so the
@@ -1159,6 +1191,10 @@ async function runLoopBody(
1159
1191
  let preserveSoftRequirementState = false;
1160
1192
 
1161
1193
  let pendingMessages: AgentMessage[] = [];
1194
+ // Steering the provider took from the queue during the last response:
1195
+ // `liveAccepted` reached the model inside it, `liveDeferred` did not.
1196
+ let liveAccepted: AgentMessage[] = [];
1197
+ let liveDeferred: AgentMessage[] = [];
1162
1198
  try {
1163
1199
  let messagesToEmit = [...initialMessages];
1164
1200
  if (isDeadlineExceeded(config.deadline)) {
@@ -1216,8 +1252,15 @@ async function runLoopBody(
1216
1252
  currentContext.messages.push(result);
1217
1253
  newMessages.push(result);
1218
1254
  }
1255
+ const resumeContextMessage = injectExecutionAdditionalContext(
1256
+ currentContext,
1257
+ newMessages,
1258
+ stream,
1259
+ executionResult.additionalContext,
1260
+ );
1219
1261
  await emitTurnEnd(stream, currentContext, resumeTail, executionResult.toolResults, config, signal, {
1220
1262
  willContinue: !isDeadlineExceeded(config.deadline),
1263
+ ...(resumeContextMessage ? { additionalMessages: [resumeContextMessage] } : {}),
1221
1264
  });
1222
1265
  turnOpen = false;
1223
1266
  // A tool hook may mark its completed result as terminal (e.g. subagent
@@ -1296,6 +1339,7 @@ async function runLoopBody(
1296
1339
  }
1297
1340
 
1298
1341
  preparedProviderCall = await prepareProviderCall(currentContext, config, signal);
1342
+ preparedProviderCall.liveSteering = openLiveSteering(config, signal, preparedProviderCall);
1299
1343
  gateResult = (await config.beforeModelCall?.(preparedProviderCall.context, signal)) || undefined;
1300
1344
  } catch (error) {
1301
1345
  if (!turnOpen) {
@@ -1422,6 +1466,12 @@ async function runLoopBody(
1422
1466
  harmonyRetryAttempt++;
1423
1467
  continue;
1424
1468
  }
1469
+ } finally {
1470
+ const channel = preparedProviderCall.liveSteering;
1471
+ if (channel) {
1472
+ liveAccepted.push(...channel.accepted);
1473
+ liveDeferred.push(...channel.deferred);
1474
+ }
1425
1475
  }
1426
1476
  if (recovered) {
1427
1477
  message = snapshotAssistantMessage(message);
@@ -1527,6 +1577,7 @@ async function runLoopBody(
1527
1577
  const softNonCompliant = softGateActive && !calledOnlyRequiredTool;
1528
1578
 
1529
1579
  const toolResults: ToolResultMessage[] = [];
1580
+ const additionalMessages: AgentMessage[] = [];
1530
1581
  if (softNonCompliant && softRequiredTool !== undefined) {
1531
1582
  SpeculativeOperationCoordinator.discardForMessage(message, "soft tool requirement deferred execution");
1532
1583
  if (softRequirementState.escalations >= MAX_SOFT_TOOL_ESCALATIONS) {
@@ -1569,13 +1620,19 @@ async function runLoopBody(
1569
1620
  telemetry,
1570
1621
  invokeAgentSpan,
1571
1622
  );
1572
-
1573
1623
  toolResults.push(...executionResult.toolResults);
1574
1624
 
1575
1625
  for (const result of toolResults) {
1576
1626
  currentContext.messages.push(result);
1577
1627
  newMessages.push(result);
1578
1628
  }
1629
+ const injectedContext = injectExecutionAdditionalContext(
1630
+ currentContext,
1631
+ newMessages,
1632
+ stream,
1633
+ executionResult.additionalContext,
1634
+ );
1635
+ if (injectedContext) additionalMessages.push(injectedContext);
1579
1636
  } else if (toolCalls.length > 0) {
1580
1637
  SpeculativeOperationCoordinator.discardForMessage(
1581
1638
  message,
@@ -1626,6 +1683,7 @@ async function runLoopBody(
1626
1683
  }
1627
1684
 
1628
1685
  await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, {
1686
+ additionalMessages,
1629
1687
  willContinue: hasMoreToolCalls && !isDeadlineExceeded(config.deadline),
1630
1688
  });
1631
1689
  turnOpen = false;
@@ -1640,16 +1698,36 @@ async function runLoopBody(
1640
1698
  // instantly aborts — message lands in history, agent never responds. The
1641
1699
  // mid-batch interrupt poll only peeks (hasSteeringMessages), so the queue
1642
1700
  // still owns every message until this dequeue.
1643
- const steering = signal?.aborted ? [] : (await config.getSteeringMessages?.(signal)) || [];
1644
- if (hasMoreToolCalls) {
1645
- // Mid-work: fold any non-interrupting asides into the next turn alongside steering.
1646
- const asides = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
1647
- pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
1701
+ // Aborted: live-taken steering stays unrecorded, so the agent returns
1702
+ // it to the queue for the continuation run.
1703
+ const live = signal?.aborted ? [] : [...liveAccepted, ...liveDeferred];
1704
+ const liveReachedModel = !signal?.aborted && liveAccepted.length > 0;
1705
+ if (liveReachedModel) {
1706
+ for (const message of liveAccepted) {
1707
+ if (message.role === "user") message.liveSteered = true;
1708
+ }
1709
+ }
1710
+ liveAccepted = [];
1711
+ liveDeferred = [];
1712
+ if (liveReachedModel) {
1713
+ // The server continues from exactly this steering; anything else
1714
+ // queued now would not line up with its continuation, so it waits
1715
+ // for the next boundary (or is steered into that response).
1716
+ pendingMessages = live;
1648
1717
  } else {
1649
- // Stop boundary: only steering (live user input) forces another turn here. Leave
1650
- // asides for the outer drain below so a passive aside can't trigger an extra model
1651
- // turn ahead of a queued follow-up — the outer drain batches asides + follow-ups together.
1652
- pendingMessages = steering;
1718
+ const steering = signal?.aborted
1719
+ ? []
1720
+ : [...live, ...((await config.getSteeringMessages?.(signal)) || [])];
1721
+ if (hasMoreToolCalls) {
1722
+ // Mid-work: fold any non-interrupting asides into the next turn alongside steering.
1723
+ const asides = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
1724
+ pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
1725
+ } else {
1726
+ // Stop boundary: only steering (live user input) forces another turn here. Leave
1727
+ // asides for the outer drain below so a passive aside can't trigger an extra model
1728
+ // turn ahead of a queued follow-up — the outer drain batches asides + follow-ups together.
1729
+ pendingMessages = steering;
1730
+ }
1653
1731
  }
1654
1732
  }
1655
1733
 
@@ -1718,6 +1796,45 @@ interface PreparedProviderCall {
1718
1796
  context: Context;
1719
1797
  promptToolWireTools: Context["tools"];
1720
1798
  ownedDialect: Dialect | undefined;
1799
+ /** Steering source offered to the provider for this call. */
1800
+ liveSteering?: LiveSteeringChannel;
1801
+ }
1802
+
1803
+ /**
1804
+ * Offer queued steering to a provider that can deliver it into the response it
1805
+ * is streaming. Latency decides whether steering lands before the model commits
1806
+ * to its next output, so claims convert only the steering batch — message-level
1807
+ * transforms (steering envelope, redaction) — never the whole transcript.
1808
+ * Provider-context transforms rewrite images, so image-bearing steering waits
1809
+ * for the boundary rather than risk bytes the next request would not replay.
1810
+ */
1811
+ function openLiveSteering(
1812
+ config: AgentLoopConfig,
1813
+ loopSignal: AbortSignal | undefined,
1814
+ prepared: PreparedProviderCall,
1815
+ ): LiveSteeringChannel | undefined {
1816
+ const { getSteeringMessages, waitForSteeringMessages } = config;
1817
+ if (!getSteeringMessages || !waitForSteeringMessages || prepared.ownedDialect) return undefined;
1818
+ const bound = (signal: AbortSignal): AbortSignal => (loopSignal ? AbortSignal.any([signal, loopSignal]) : signal);
1819
+ return new LiveSteeringChannel({
1820
+ wait: signal => waitForSteeringMessages(bound(signal)),
1821
+ take: signal => getSteeringMessages(bound(signal)),
1822
+ toProvider: async (messages, signal) => {
1823
+ const transformed = config.transformContext
1824
+ ? await config.transformContext(messages, bound(signal))
1825
+ : messages;
1826
+ const converted = normalizeMessagesForProvider(await config.convertToLlm(transformed), prepared.model);
1827
+ const userMessages: UserMessage[] = [];
1828
+ for (const message of converted) {
1829
+ if (message.role !== "user") return undefined;
1830
+ if (typeof message.content !== "string" && message.content.some(part => part.type === "image")) {
1831
+ return undefined;
1832
+ }
1833
+ userMessages.push(message);
1834
+ }
1835
+ return userMessages.length > 0 ? userMessages : undefined;
1836
+ },
1837
+ });
1721
1838
  }
1722
1839
 
1723
1840
  async function prepareProviderCall(
@@ -1733,7 +1850,8 @@ async function prepareProviderCall(
1733
1850
 
1734
1851
  const llmMessages = await config.convertToLlm(messages);
1735
1852
  const normalizedMessages = normalizeMessagesForProvider(llmMessages, model);
1736
- const ownedDialect: Dialect | undefined = config.dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
1853
+ const ownedDialect: Dialect | undefined =
1854
+ (config.getDialect ? config.getDialect(model) : config.dialect) ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
1737
1855
  const pruneToolDescriptions = !!config.pruneToolDescriptions && !ownedDialect;
1738
1856
  let llmContext: Context;
1739
1857
  if (config.appendOnlyContext) {
@@ -1902,6 +2020,7 @@ async function streamAssistantResponse(
1902
2020
  cwd: effectiveCwd,
1903
2021
  signal: finalRequestSignal,
1904
2022
  onResponse: captureOnResponse,
2023
+ liveSteering: providerCall.liveSteering,
1905
2024
  });
1906
2025
  if (promptToolWireTools && ownedDialect) {
1907
2026
  // Re-materialize in-band tool-call text as native toolCall content blocks
@@ -2572,6 +2691,12 @@ interface PreparedToolCall {
2572
2691
  tool: AgentTool<any> | undefined;
2573
2692
  /** Validated (possibly hook-revised) execution args; raw args when validation failed. */
2574
2693
  args: Record<string, unknown>;
2694
+ /**
2695
+ * Passive context returned by `beforeToolCall`. Committed after the batch
2696
+ * settles only when the call's final result is not an error, so a call the
2697
+ * tool's own approval gate denies (or that otherwise fails) injects nothing.
2698
+ */
2699
+ additionalContext?: string;
2575
2700
  /** Transformed args shared by final reconciliation and eventual dispatch. */
2576
2701
  executionArgs?: Record<string, unknown>;
2577
2702
  /** Transform failure retained for execution's scheduled error result. */
@@ -2713,6 +2838,19 @@ async function prepareToolCallDispatch(
2713
2838
  if (intentTracing) {
2714
2839
  const { intent, strippedArgs } = extractIntent(toolCall.arguments);
2715
2840
  argsForExecution = strippedArgs;
2841
+ // A payload in `i` would be stripped and the tool run with the leftover
2842
+ // args. Unknown tools fall through to the not-found error; a tool that
2843
+ // owns `i` as a real parameter has nowhere else to put the value.
2844
+ if (
2845
+ intent !== undefined &&
2846
+ intent.length > MAX_INTENT_LENGTH &&
2847
+ tool &&
2848
+ !schemaDefinesProperty(toolWireSchema(tool), INTENT_FIELD)
2849
+ ) {
2850
+ entry.args = strippedArgs;
2851
+ entry.validationErrorMessage = `\`${INTENT_FIELD}\` is a short intent label (at most ${MAX_INTENT_LENGTH} chars); the value you sent is ${intent.length} chars. The tool was not run. Put that content in the tool's own parameters and retry with a brief \`${INTENT_FIELD}\`.`;
2852
+ continue;
2853
+ }
2716
2854
  if (intent) {
2717
2855
  toolCall.intent = intent;
2718
2856
  } else if (typeof tool?.intent === "function") {
@@ -2766,6 +2904,9 @@ async function prepareToolCallDispatch(
2766
2904
  entry.blockReason = beforeResult.reason;
2767
2905
  continue;
2768
2906
  }
2907
+ if (isNonBlankContext(beforeResult?.additionalContext)) {
2908
+ entry.additionalContext = beforeResult.additionalContext;
2909
+ }
2769
2910
  if (beforeResult?.args !== undefined) {
2770
2911
  // Revalidate: a hook revision is untrusted input to the tool schema.
2771
2912
  const revised = validate(beforeResult.args);
@@ -2850,7 +2991,8 @@ async function speculativeFinalCalls(
2850
2991
  }
2851
2992
 
2852
2993
  /**
2853
- * Execute tool calls from an assistant message.
2994
+ * Execute tool calls from an assistant message. Returns model-visible context
2995
+ * only after every result has settled, preserving assistant call order.
2854
2996
  */
2855
2997
  async function executeToolCalls(
2856
2998
  currentContext: AgentContext,
@@ -2860,7 +3002,7 @@ async function executeToolCalls(
2860
3002
  config: AgentLoopConfig,
2861
3003
  telemetry: AgentTelemetry | undefined,
2862
3004
  invokeAgentSpan: Span | undefined,
2863
- ): Promise<{ toolResults: ToolResultMessage[] }> {
3005
+ ): Promise<{ toolResults: ToolResultMessage[]; additionalContext?: string }> {
2864
3006
  const tools = currentContext.tools;
2865
3007
  const {
2866
3008
  hasSteeringMessages,
@@ -2952,6 +3094,8 @@ async function executeToolCalls(
2952
3094
  blocked: prepared.blocked === true,
2953
3095
  blockReason: prepared.blockReason,
2954
3096
  prepareError: prepared.prepareError,
3097
+ preparedContext: prepared.additionalContext,
3098
+ reportedContext: [] as string[],
2955
3099
  executionArgs: prepared.executionArgs,
2956
3100
  transformError: prepared.transformError,
2957
3101
  };
@@ -3184,11 +3328,13 @@ async function executeToolCalls(
3184
3328
  }
3185
3329
 
3186
3330
  if (!completedToolExecution) {
3187
- // The cooperative steering signal rides the loop-owned
3188
- // ToolCallContext (surfacing as `ctx.toolCall.steeringSignal`):
3189
- // AgentToolContext itself is app-built via declaration merging, so
3190
- // the loop cannot construct or extend one structurally.
3191
- const streamSession = speculationCoordinator?.takeStreamSession(toolCall.id);
3331
+ // The cooperative steering signal and the passive-context sink
3332
+ // ride the loop-owned ToolCallContext (surfacing as
3333
+ // `ctx.toolCall.*`); the host surfaces the sink on the context it
3334
+ // builds, and the loop hands that object to the tool untouched.
3335
+ // Wrapper-dispatched nested calls (for example `write xd://…`)
3336
+ // inherit the context, so their passive hook context joins this
3337
+ // root call at the batch boundary.
3192
3338
  const toolContext = getToolContext?.({
3193
3339
  batchId,
3194
3340
  index,
@@ -3196,7 +3342,11 @@ async function executeToolCalls(
3196
3342
  toolCalls: toolCallInfos,
3197
3343
  steeringSignal: steeringSoftController.signal,
3198
3344
  providerMetadata: toolCall.providerMetadata,
3345
+ addAdditionalContext: context => {
3346
+ if (isNonBlankContext(context)) record.reportedContext.push(context);
3347
+ },
3199
3348
  });
3349
+ const streamSession = speculationCoordinator?.takeStreamSession(toolCall.id);
3200
3350
  if (streamSession && toolContext) {
3201
3351
  toolContext[SPECULATIVE_STREAM_SESSION] = streamSession;
3202
3352
  } else if (streamSession && !streamSession.contextIndependent) {
@@ -3449,7 +3599,23 @@ async function executeToolCalls(
3449
3599
  }
3450
3600
  await speculationCoordinator?.discardAll("candidate was not dispatched");
3451
3601
 
3452
- return { toolResults: emittedToolResults };
3602
+ // Skipped calls never ran. Hook-prepared context also requires a non-error
3603
+ // final result; context the tool itself reported during execution stands.
3604
+ // Within a call, tool-reported context (including nested `xd://` dispatch)
3605
+ // precedes the hook's: wrappers release hook context only after the call
3606
+ // succeeds, so this is the one order every dispatch path can produce.
3607
+ const additionalContext = joinAdditionalContext(
3608
+ records
3609
+ .filter(record => !record.skipped)
3610
+ .flatMap(record => [
3611
+ ...record.reportedContext,
3612
+ record.toolResultMessage?.isError ? undefined : record.preparedContext,
3613
+ ]),
3614
+ );
3615
+ return {
3616
+ toolResults: emittedToolResults,
3617
+ ...(additionalContext !== undefined ? { additionalContext } : {}),
3618
+ };
3453
3619
  }
3454
3620
 
3455
3621
  /**
package/src/agent.ts CHANGED
@@ -40,6 +40,12 @@ import type { AppendOnlyContextManager } from "./append-only-context";
40
40
  import { isProviderRefusalMessage } from "./replay-policy";
41
41
  import { SentToolDefinitions } from "./sent-tool-definitions";
42
42
  import { Tokenizer, tokenizerEncodingForModel } from "./tokenizer";
43
+ import {
44
+ createAdditionalContextMessage,
45
+ joinAdditionalContext,
46
+ TOOL_RESULT_ADDITIONAL_CONTEXT,
47
+ type ToolResultWithAdditionalContext,
48
+ } from "./tool-context";
43
49
  import type {
44
50
  AgentBeforeModelCall,
45
51
  AgentContext,
@@ -66,7 +72,7 @@ import { EventLoopKeepalive } from "./utils/yield";
66
72
  function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
67
73
  return messages.filter((m): m is Message => {
68
74
  if (m.role === "assistant") return !isProviderRefusalMessage(m);
69
- return m.role === "user" || m.role === "toolResult";
75
+ return m.role === "user" || m.role === "developer" || m.role === "toolResult";
70
76
  });
71
77
  }
72
78
 
@@ -272,6 +278,11 @@ export interface AgentOptions {
272
278
  pruneToolDescriptions?: boolean;
273
279
  /** Owned tool-calling dialect. Undefined keeps provider-native tool calling. */
274
280
  dialect?: Dialect;
281
+ /**
282
+ * Per-request owned-dialect resolver, consulted with the model being requested.
283
+ * Authoritative when set (like {@link serviceTierResolver}): replaces {@link dialect}.
284
+ */
285
+ dialectResolver?: (model: Model) => Dialect | undefined;
275
286
  /**
276
287
  * When owned tool calling is active and the model fabricates a tool result
277
288
  * mid-turn: `true` (default) aborts the provider request immediately; `false`
@@ -362,6 +373,12 @@ interface CursorToolResultEntry {
362
373
  * `message_end` lands in the same chunk as the tool result.
363
374
  */
364
375
  pending?: Promise<void>;
376
+ /**
377
+ * Passive context the executor attached via
378
+ * {@link TOOL_RESULT_ADDITIONAL_CONTEXT}, captured before any transformer
379
+ * can replace the message. Injected after the buffered results.
380
+ */
381
+ additionalContext?: string;
365
382
  }
366
383
 
367
384
  type QueuedMessageQueue = "steering" | "followUp";
@@ -441,6 +458,7 @@ export class Agent {
441
458
  #intentTracing: boolean;
442
459
  #pruneToolDescriptions: boolean;
443
460
  #dialect?: Dialect;
461
+ #dialectResolver?: (model: Model) => Dialect | undefined;
444
462
  #abortOnFabricatedToolResult?: boolean;
445
463
  #getToolChoice?: () => ToolChoiceDirective | undefined;
446
464
  #onToolChoiceUnavailable?: () => void;
@@ -540,6 +558,7 @@ export class Agent {
540
558
  this.#intentTracing = opts.intentTracing === true;
541
559
  this.#pruneToolDescriptions = opts.pruneToolDescriptions === true;
542
560
  this.#dialect = opts.dialect;
561
+ this.#dialectResolver = opts.dialectResolver;
543
562
  this.#abortOnFabricatedToolResult = opts.abortOnFabricatedToolResult;
544
563
  this.#getToolChoice = opts.getToolChoice;
545
564
  this.#onToolChoiceUnavailable = opts.onToolChoiceUnavailable;
@@ -749,6 +768,33 @@ export class Agent {
749
768
  this.#hideThinkingSummary = value;
750
769
  }
751
770
 
771
+ /** Strip tool descriptions from provider-bound specs; read per request. */
772
+ get pruneToolDescriptions(): boolean {
773
+ return this.#pruneToolDescriptions;
774
+ }
775
+
776
+ set pruneToolDescriptions(value: boolean) {
777
+ this.#pruneToolDescriptions = value;
778
+ }
779
+
780
+ /** Inject/strip the intent field on tool calls; applies from the next prompt run. */
781
+ get intentTracing(): boolean {
782
+ return this.#intentTracing;
783
+ }
784
+
785
+ set intentTracing(value: boolean) {
786
+ this.#intentTracing = value;
787
+ }
788
+
789
+ /** Abort the provider request on a fabricated tool result; applies from the next prompt run. */
790
+ get abortOnFabricatedToolResult(): boolean | undefined {
791
+ return this.#abortOnFabricatedToolResult;
792
+ }
793
+
794
+ set abortOnFabricatedToolResult(value: boolean | undefined) {
795
+ this.#abortOnFabricatedToolResult = value;
796
+ }
797
+
752
798
  /**
753
799
  * Get the current max retry delay in milliseconds.
754
800
  */
@@ -829,7 +875,9 @@ export class Agent {
829
875
  ): Promise<Context> {
830
876
  const model = this.#state.model;
831
877
  if (!model) throw new Error("No active model on agent");
832
- const ownedDialect = this.#dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
878
+ const ownedDialect =
879
+ (this.#dialectResolver ? this.#dialectResolver(model) : this.#dialect) ??
880
+ resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
833
881
  const messages = normalizeMessagesForProvider(llmMessages, model);
834
882
  const tools = ownedDialect
835
883
  ? []
@@ -1518,7 +1566,10 @@ export class Agent {
1518
1566
  // that, a transformer resolving after the swap would patch a detached
1519
1567
  // object while the persisted result kept the original payload — the
1520
1568
  // rewrite silently lost.
1521
- const entry: CursorToolResultEntry = { toolResult: message };
1569
+ const entry: CursorToolResultEntry = {
1570
+ toolResult: message,
1571
+ additionalContext: (message as ToolResultWithAdditionalContext)[TOOL_RESULT_ADDITIONAL_CONTEXT],
1572
+ };
1522
1573
  this.#cursorToolResultBuffer.push(entry);
1523
1574
  const transform = this.#cursorOnToolResult;
1524
1575
  if (transform) {
@@ -1624,6 +1675,7 @@ export class Agent {
1624
1675
  intentTracing: this.#intentTracing,
1625
1676
  pruneToolDescriptions: this.#pruneToolDescriptions,
1626
1677
  dialect: this.#dialect,
1678
+ getDialect: this.#dialectResolver,
1627
1679
  abortOnFabricatedToolResult: this.#abortOnFabricatedToolResult,
1628
1680
  appendOnlyContext: this.#appendOnlyContext,
1629
1681
  beforeToolCall: this.beforeToolCall ? (ctx, signal) => this.beforeToolCall?.(ctx, signal) : undefined,
@@ -1781,6 +1833,9 @@ export class Agent {
1781
1833
  .map(entry => entry.pending);
1782
1834
  if (pendingTransforms.length > 0) await Promise.all(pendingTransforms);
1783
1835
  const bufferedCursorResults = this.#cursorToolResultBuffer.map(({ toolResult }) => toolResult);
1836
+ const bufferedCursorContext = joinAdditionalContext(
1837
+ this.#cursorToolResultBuffer.map(({ additionalContext }) => additionalContext),
1838
+ );
1784
1839
  const retainedToolCallIds = new Set(completedToolCallIds);
1785
1840
  for (const { toolCallId } of bufferedCursorResults) retainedToolCallIds.add(toolCallId);
1786
1841
  const errorMsg: AssistantMessage =
@@ -1857,9 +1912,13 @@ export class Agent {
1857
1912
  this.#emit({ type: "message_end", message: toolResult });
1858
1913
  toolResults.push(toolResult);
1859
1914
  }
1915
+ const agentEndMessages: AgentMessage[] = [errorMsg, ...toolResults];
1916
+ if (bufferedCursorContext !== undefined) {
1917
+ agentEndMessages.push(this.#emitCursorAdditionalContext(bufferedCursorContext));
1918
+ }
1860
1919
  this.#emit({ type: "turn_end", message: errorMsg, toolResults });
1861
1920
  turnOpen = false;
1862
- this.#emit({ type: "agent_end", messages: [errorMsg, ...toolResults] });
1921
+ this.#emit({ type: "agent_end", messages: agentEndMessages });
1863
1922
  } else {
1864
1923
  this.appendMessage(errorMsg);
1865
1924
  this.#state.error = errorMessage;
@@ -1931,8 +1990,24 @@ export class Agent {
1931
1990
  this.appendMessage(toolResult);
1932
1991
  this.#emit({ type: "message_end", message: toolResult });
1933
1992
  }
1993
+ const additionalContext = joinAdditionalContext(buffer.map(entry => entry.additionalContext));
1994
+ if (additionalContext !== undefined) this.#emitCursorAdditionalContext(additionalContext);
1934
1995
  } finally {
1935
1996
  this.#cursorToolResultDrain = undefined;
1936
1997
  }
1937
1998
  }
1999
+
2000
+ /**
2001
+ * Append passive context reported by Cursor exec-channel tools after their
2002
+ * results, mirroring the loop's post-batch developer message. Cursor runs
2003
+ * those tools server-side mid-stream, so the context reaches the next
2004
+ * provider request instead of the current one.
2005
+ */
2006
+ #emitCursorAdditionalContext(text: string): AgentMessage {
2007
+ const message = createAdditionalContextMessage(text);
2008
+ this.#emit({ type: "message_start", message });
2009
+ this.appendMessage(message);
2010
+ this.#emit({ type: "message_end", message });
2011
+ return message;
2012
+ }
1938
2013
  }
@@ -255,7 +255,7 @@ export async function requestAnthropicNativeCompaction(
255
255
  throw new Error(
256
256
  response.stopDetails?.type === "compaction"
257
257
  ? "Anthropic compaction returned no signed summary"
258
- : "Anthropic compaction response carried no compaction block",
258
+ : `Anthropic compaction response carried no compaction block (stop reason: ${response.stopDetails?.type ?? response.stopReason})`,
259
259
  );
260
260
  }
261
261
  return {
@@ -64,6 +64,13 @@ export interface CompactionSummaryMessage {
64
64
  images?: ImageContent[];
65
65
  /** Post-pass dead-end warning attached to this compaction (progress guard). */
66
66
  warning?: string;
67
+ /**
68
+ * Thinking-binding rewrite marker when it must differ from `timestamp`: a
69
+ * natively replayed summary predates it before the retained tail so that
70
+ * tail's bound thinking stays valid. `timestamp` remains the commit time,
71
+ * which is what invalidates the tail's pre-compaction usage reports.
72
+ */
73
+ historyRewriteAt?: number;
67
74
  timestamp: number;
68
75
  }
69
76
 
@@ -135,6 +142,8 @@ export interface CompactionSummaryMessageOptions {
135
142
  method?: string;
136
143
  /** Estimated context tokens after the rewrite, for display alongside `tokensBefore`. */
137
144
  tokensAfter?: number;
145
+ /** See {@link CompactionSummaryMessage.historyRewriteAt}. */
146
+ historyRewriteAt?: number;
138
147
  }
139
148
 
140
149
  export function createCompactionSummaryMessage(
@@ -143,7 +152,7 @@ export function createCompactionSummaryMessage(
143
152
  timestamp: string,
144
153
  options: CompactionSummaryMessageOptions = {},
145
154
  ): CompactionSummaryMessage {
146
- const { shortSummary, providerPayload, images, blocks, warning, method, tokensAfter } = options;
155
+ const { shortSummary, providerPayload, images, blocks, warning, method, tokensAfter, historyRewriteAt } = options;
147
156
  const imageBlocks =
148
157
  blocks?.filter((block): block is ImageContent => block.type === "image") ??
149
158
  (images && images.length > 0 ? images : undefined);
@@ -158,6 +167,7 @@ export function createCompactionSummaryMessage(
158
167
  blocks: blocks && blocks.length > 0 ? blocks : undefined,
159
168
  images: imageBlocks && imageBlocks.length > 0 ? imageBlocks : undefined,
160
169
  warning,
170
+ historyRewriteAt,
161
171
  timestamp: new Date(timestamp).getTime(),
162
172
  };
163
173
  }
@@ -246,7 +256,7 @@ export function convertMessageToLlm(message: AgentMessage): Message | undefined
246
256
  ...(message.images ?? []),
247
257
  ],
248
258
  attribution: "agent",
249
- historyRewriteAt: message.timestamp,
259
+ historyRewriteAt: message.historyRewriteAt ?? message.timestamp,
250
260
  providerPayload: message.providerPayload,
251
261
  timestamp: message.timestamp,
252
262
  };
@@ -120,7 +120,7 @@ function createPrunedNotice(tokens: number): string {
120
120
  * own rules: useless already drops no-savings candidates, superseded prunes for
121
121
  * correctness regardless of size.
122
122
  */
123
- const MIN_PRUNE_TOKENS = 50;
123
+ export const MIN_PRUNE_TOKENS = 50;
124
124
 
125
125
  function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined {
126
126
  if (entry.type !== "message") return undefined;