@oh-my-pi/pi-agent-core 18.3.0 → 18.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 {
@@ -51,6 +52,7 @@ import {
51
52
  } from "@oh-my-pi/pi-ai/utils/harmony-leak";
52
53
  import { logger, sanitizeText, structuredCloneJSON } from "@oh-my-pi/pi-utils";
53
54
  import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
55
+ import { LiveSteeringChannel } from "./live-steering";
54
56
  import { agentPauseGate } from "./pause";
55
57
  import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
56
58
  import { SpeculativeOperationCoordinator } from "./speculative-execution";
@@ -70,6 +72,7 @@ import {
70
72
  startExecuteToolSpan,
71
73
  startInvokeAgentSpan,
72
74
  } from "./telemetry";
75
+ import { createAdditionalContextMessage, isNonBlankContext, joinAdditionalContext } from "./tool-context";
73
76
  import type {
74
77
  AgentContext,
75
78
  AgentEvent,
@@ -748,6 +751,7 @@ async function emitTurnEnd(
748
751
  await config.onTurnEnd?.(currentContext.messages, terminalYield ? undefined : signal, {
749
752
  message,
750
753
  toolResults,
754
+ additionalMessages: [],
751
755
  willContinue: false,
752
756
  ...context,
753
757
  });
@@ -1096,6 +1100,26 @@ function emitInputMessages(stream: EventStream<AgentEvent, AgentMessage[]>, mess
1096
1100
  }
1097
1101
  }
1098
1102
 
1103
+ /**
1104
+ * Append passive tool-call context after its results as a developer message.
1105
+ * Returns the injected message for turn-end bookkeeping, or undefined when
1106
+ * there is nothing to inject. Shared by the normal tool-call path and the
1107
+ * resume-tail replay so replayed calls deliver context identically.
1108
+ */
1109
+ function injectExecutionAdditionalContext(
1110
+ currentContext: AgentContext,
1111
+ newMessages: AgentMessage[],
1112
+ stream: EventStream<AgentEvent, AgentMessage[]>,
1113
+ additionalContext: string | undefined,
1114
+ ): AgentMessage | undefined {
1115
+ if (additionalContext === undefined) return undefined;
1116
+ const contextMessage = createAdditionalContextMessage(additionalContext);
1117
+ currentContext.messages.push(contextMessage);
1118
+ newMessages.push(contextMessage);
1119
+ emitInputMessages(stream, [contextMessage]);
1120
+ return contextMessage;
1121
+ }
1122
+
1099
1123
  /**
1100
1124
  * Resolve aside entries at the moment the loop is about to inject them. Each entry
1101
1125
  * is either a ready {@link AgentMessage} or a sync thunk evaluated here so the
@@ -1159,6 +1183,10 @@ async function runLoopBody(
1159
1183
  let preserveSoftRequirementState = false;
1160
1184
 
1161
1185
  let pendingMessages: AgentMessage[] = [];
1186
+ // Steering the provider took from the queue during the last response:
1187
+ // `liveAccepted` reached the model inside it, `liveDeferred` did not.
1188
+ let liveAccepted: AgentMessage[] = [];
1189
+ let liveDeferred: AgentMessage[] = [];
1162
1190
  try {
1163
1191
  let messagesToEmit = [...initialMessages];
1164
1192
  if (isDeadlineExceeded(config.deadline)) {
@@ -1216,8 +1244,15 @@ async function runLoopBody(
1216
1244
  currentContext.messages.push(result);
1217
1245
  newMessages.push(result);
1218
1246
  }
1247
+ const resumeContextMessage = injectExecutionAdditionalContext(
1248
+ currentContext,
1249
+ newMessages,
1250
+ stream,
1251
+ executionResult.additionalContext,
1252
+ );
1219
1253
  await emitTurnEnd(stream, currentContext, resumeTail, executionResult.toolResults, config, signal, {
1220
1254
  willContinue: !isDeadlineExceeded(config.deadline),
1255
+ ...(resumeContextMessage ? { additionalMessages: [resumeContextMessage] } : {}),
1221
1256
  });
1222
1257
  turnOpen = false;
1223
1258
  // A tool hook may mark its completed result as terminal (e.g. subagent
@@ -1296,6 +1331,7 @@ async function runLoopBody(
1296
1331
  }
1297
1332
 
1298
1333
  preparedProviderCall = await prepareProviderCall(currentContext, config, signal);
1334
+ preparedProviderCall.liveSteering = openLiveSteering(config, signal, preparedProviderCall);
1299
1335
  gateResult = (await config.beforeModelCall?.(preparedProviderCall.context, signal)) || undefined;
1300
1336
  } catch (error) {
1301
1337
  if (!turnOpen) {
@@ -1422,6 +1458,12 @@ async function runLoopBody(
1422
1458
  harmonyRetryAttempt++;
1423
1459
  continue;
1424
1460
  }
1461
+ } finally {
1462
+ const channel = preparedProviderCall.liveSteering;
1463
+ if (channel) {
1464
+ liveAccepted.push(...channel.accepted);
1465
+ liveDeferred.push(...channel.deferred);
1466
+ }
1425
1467
  }
1426
1468
  if (recovered) {
1427
1469
  message = snapshotAssistantMessage(message);
@@ -1527,6 +1569,7 @@ async function runLoopBody(
1527
1569
  const softNonCompliant = softGateActive && !calledOnlyRequiredTool;
1528
1570
 
1529
1571
  const toolResults: ToolResultMessage[] = [];
1572
+ const additionalMessages: AgentMessage[] = [];
1530
1573
  if (softNonCompliant && softRequiredTool !== undefined) {
1531
1574
  SpeculativeOperationCoordinator.discardForMessage(message, "soft tool requirement deferred execution");
1532
1575
  if (softRequirementState.escalations >= MAX_SOFT_TOOL_ESCALATIONS) {
@@ -1569,13 +1612,19 @@ async function runLoopBody(
1569
1612
  telemetry,
1570
1613
  invokeAgentSpan,
1571
1614
  );
1572
-
1573
1615
  toolResults.push(...executionResult.toolResults);
1574
1616
 
1575
1617
  for (const result of toolResults) {
1576
1618
  currentContext.messages.push(result);
1577
1619
  newMessages.push(result);
1578
1620
  }
1621
+ const injectedContext = injectExecutionAdditionalContext(
1622
+ currentContext,
1623
+ newMessages,
1624
+ stream,
1625
+ executionResult.additionalContext,
1626
+ );
1627
+ if (injectedContext) additionalMessages.push(injectedContext);
1579
1628
  } else if (toolCalls.length > 0) {
1580
1629
  SpeculativeOperationCoordinator.discardForMessage(
1581
1630
  message,
@@ -1626,6 +1675,7 @@ async function runLoopBody(
1626
1675
  }
1627
1676
 
1628
1677
  await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, {
1678
+ additionalMessages,
1629
1679
  willContinue: hasMoreToolCalls && !isDeadlineExceeded(config.deadline),
1630
1680
  });
1631
1681
  turnOpen = false;
@@ -1640,16 +1690,36 @@ async function runLoopBody(
1640
1690
  // instantly aborts — message lands in history, agent never responds. The
1641
1691
  // mid-batch interrupt poll only peeks (hasSteeringMessages), so the queue
1642
1692
  // 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;
1693
+ // Aborted: live-taken steering stays unrecorded, so the agent returns
1694
+ // it to the queue for the continuation run.
1695
+ const live = signal?.aborted ? [] : [...liveAccepted, ...liveDeferred];
1696
+ const liveReachedModel = !signal?.aborted && liveAccepted.length > 0;
1697
+ if (liveReachedModel) {
1698
+ for (const message of liveAccepted) {
1699
+ if (message.role === "user") message.liveSteered = true;
1700
+ }
1701
+ }
1702
+ liveAccepted = [];
1703
+ liveDeferred = [];
1704
+ if (liveReachedModel) {
1705
+ // The server continues from exactly this steering; anything else
1706
+ // queued now would not line up with its continuation, so it waits
1707
+ // for the next boundary (or is steered into that response).
1708
+ pendingMessages = live;
1648
1709
  } 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;
1710
+ const steering = signal?.aborted
1711
+ ? []
1712
+ : [...live, ...((await config.getSteeringMessages?.(signal)) || [])];
1713
+ if (hasMoreToolCalls) {
1714
+ // Mid-work: fold any non-interrupting asides into the next turn alongside steering.
1715
+ const asides = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
1716
+ pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
1717
+ } else {
1718
+ // Stop boundary: only steering (live user input) forces another turn here. Leave
1719
+ // asides for the outer drain below so a passive aside can't trigger an extra model
1720
+ // turn ahead of a queued follow-up — the outer drain batches asides + follow-ups together.
1721
+ pendingMessages = steering;
1722
+ }
1653
1723
  }
1654
1724
  }
1655
1725
 
@@ -1718,6 +1788,45 @@ interface PreparedProviderCall {
1718
1788
  context: Context;
1719
1789
  promptToolWireTools: Context["tools"];
1720
1790
  ownedDialect: Dialect | undefined;
1791
+ /** Steering source offered to the provider for this call. */
1792
+ liveSteering?: LiveSteeringChannel;
1793
+ }
1794
+
1795
+ /**
1796
+ * Offer queued steering to a provider that can deliver it into the response it
1797
+ * is streaming. Latency decides whether steering lands before the model commits
1798
+ * to its next output, so claims convert only the steering batch — message-level
1799
+ * transforms (steering envelope, redaction) — never the whole transcript.
1800
+ * Provider-context transforms rewrite images, so image-bearing steering waits
1801
+ * for the boundary rather than risk bytes the next request would not replay.
1802
+ */
1803
+ function openLiveSteering(
1804
+ config: AgentLoopConfig,
1805
+ loopSignal: AbortSignal | undefined,
1806
+ prepared: PreparedProviderCall,
1807
+ ): LiveSteeringChannel | undefined {
1808
+ const { getSteeringMessages, waitForSteeringMessages } = config;
1809
+ if (!getSteeringMessages || !waitForSteeringMessages || prepared.ownedDialect) return undefined;
1810
+ const bound = (signal: AbortSignal): AbortSignal => (loopSignal ? AbortSignal.any([signal, loopSignal]) : signal);
1811
+ return new LiveSteeringChannel({
1812
+ wait: signal => waitForSteeringMessages(bound(signal)),
1813
+ take: signal => getSteeringMessages(bound(signal)),
1814
+ toProvider: async (messages, signal) => {
1815
+ const transformed = config.transformContext
1816
+ ? await config.transformContext(messages, bound(signal))
1817
+ : messages;
1818
+ const converted = normalizeMessagesForProvider(await config.convertToLlm(transformed), prepared.model);
1819
+ const userMessages: UserMessage[] = [];
1820
+ for (const message of converted) {
1821
+ if (message.role !== "user") return undefined;
1822
+ if (typeof message.content !== "string" && message.content.some(part => part.type === "image")) {
1823
+ return undefined;
1824
+ }
1825
+ userMessages.push(message);
1826
+ }
1827
+ return userMessages.length > 0 ? userMessages : undefined;
1828
+ },
1829
+ });
1721
1830
  }
1722
1831
 
1723
1832
  async function prepareProviderCall(
@@ -1733,7 +1842,8 @@ async function prepareProviderCall(
1733
1842
 
1734
1843
  const llmMessages = await config.convertToLlm(messages);
1735
1844
  const normalizedMessages = normalizeMessagesForProvider(llmMessages, model);
1736
- const ownedDialect: Dialect | undefined = config.dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
1845
+ const ownedDialect: Dialect | undefined =
1846
+ (config.getDialect ? config.getDialect(model) : config.dialect) ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
1737
1847
  const pruneToolDescriptions = !!config.pruneToolDescriptions && !ownedDialect;
1738
1848
  let llmContext: Context;
1739
1849
  if (config.appendOnlyContext) {
@@ -1902,6 +2012,7 @@ async function streamAssistantResponse(
1902
2012
  cwd: effectiveCwd,
1903
2013
  signal: finalRequestSignal,
1904
2014
  onResponse: captureOnResponse,
2015
+ liveSteering: providerCall.liveSteering,
1905
2016
  });
1906
2017
  if (promptToolWireTools && ownedDialect) {
1907
2018
  // Re-materialize in-band tool-call text as native toolCall content blocks
@@ -2572,6 +2683,12 @@ interface PreparedToolCall {
2572
2683
  tool: AgentTool<any> | undefined;
2573
2684
  /** Validated (possibly hook-revised) execution args; raw args when validation failed. */
2574
2685
  args: Record<string, unknown>;
2686
+ /**
2687
+ * Passive context returned by `beforeToolCall`. Committed after the batch
2688
+ * settles only when the call's final result is not an error, so a call the
2689
+ * tool's own approval gate denies (or that otherwise fails) injects nothing.
2690
+ */
2691
+ additionalContext?: string;
2575
2692
  /** Transformed args shared by final reconciliation and eventual dispatch. */
2576
2693
  executionArgs?: Record<string, unknown>;
2577
2694
  /** Transform failure retained for execution's scheduled error result. */
@@ -2766,6 +2883,9 @@ async function prepareToolCallDispatch(
2766
2883
  entry.blockReason = beforeResult.reason;
2767
2884
  continue;
2768
2885
  }
2886
+ if (isNonBlankContext(beforeResult?.additionalContext)) {
2887
+ entry.additionalContext = beforeResult.additionalContext;
2888
+ }
2769
2889
  if (beforeResult?.args !== undefined) {
2770
2890
  // Revalidate: a hook revision is untrusted input to the tool schema.
2771
2891
  const revised = validate(beforeResult.args);
@@ -2850,7 +2970,8 @@ async function speculativeFinalCalls(
2850
2970
  }
2851
2971
 
2852
2972
  /**
2853
- * Execute tool calls from an assistant message.
2973
+ * Execute tool calls from an assistant message. Returns model-visible context
2974
+ * only after every result has settled, preserving assistant call order.
2854
2975
  */
2855
2976
  async function executeToolCalls(
2856
2977
  currentContext: AgentContext,
@@ -2860,7 +2981,7 @@ async function executeToolCalls(
2860
2981
  config: AgentLoopConfig,
2861
2982
  telemetry: AgentTelemetry | undefined,
2862
2983
  invokeAgentSpan: Span | undefined,
2863
- ): Promise<{ toolResults: ToolResultMessage[] }> {
2984
+ ): Promise<{ toolResults: ToolResultMessage[]; additionalContext?: string }> {
2864
2985
  const tools = currentContext.tools;
2865
2986
  const {
2866
2987
  hasSteeringMessages,
@@ -2952,6 +3073,8 @@ async function executeToolCalls(
2952
3073
  blocked: prepared.blocked === true,
2953
3074
  blockReason: prepared.blockReason,
2954
3075
  prepareError: prepared.prepareError,
3076
+ preparedContext: prepared.additionalContext,
3077
+ reportedContext: [] as string[],
2955
3078
  executionArgs: prepared.executionArgs,
2956
3079
  transformError: prepared.transformError,
2957
3080
  };
@@ -3184,11 +3307,13 @@ async function executeToolCalls(
3184
3307
  }
3185
3308
 
3186
3309
  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);
3310
+ // The cooperative steering signal and the passive-context sink
3311
+ // ride the loop-owned ToolCallContext (surfacing as
3312
+ // `ctx.toolCall.*`); the host surfaces the sink on the context it
3313
+ // builds, and the loop hands that object to the tool untouched.
3314
+ // Wrapper-dispatched nested calls (for example `write xd://…`)
3315
+ // inherit the context, so their passive hook context joins this
3316
+ // root call at the batch boundary.
3192
3317
  const toolContext = getToolContext?.({
3193
3318
  batchId,
3194
3319
  index,
@@ -3196,7 +3321,11 @@ async function executeToolCalls(
3196
3321
  toolCalls: toolCallInfos,
3197
3322
  steeringSignal: steeringSoftController.signal,
3198
3323
  providerMetadata: toolCall.providerMetadata,
3324
+ addAdditionalContext: context => {
3325
+ if (isNonBlankContext(context)) record.reportedContext.push(context);
3326
+ },
3199
3327
  });
3328
+ const streamSession = speculationCoordinator?.takeStreamSession(toolCall.id);
3200
3329
  if (streamSession && toolContext) {
3201
3330
  toolContext[SPECULATIVE_STREAM_SESSION] = streamSession;
3202
3331
  } else if (streamSession && !streamSession.contextIndependent) {
@@ -3449,7 +3578,23 @@ async function executeToolCalls(
3449
3578
  }
3450
3579
  await speculationCoordinator?.discardAll("candidate was not dispatched");
3451
3580
 
3452
- return { toolResults: emittedToolResults };
3581
+ // Skipped calls never ran. Hook-prepared context also requires a non-error
3582
+ // final result; context the tool itself reported during execution stands.
3583
+ // Within a call, tool-reported context (including nested `xd://` dispatch)
3584
+ // precedes the hook's: wrappers release hook context only after the call
3585
+ // succeeds, so this is the one order every dispatch path can produce.
3586
+ const additionalContext = joinAdditionalContext(
3587
+ records
3588
+ .filter(record => !record.skipped)
3589
+ .flatMap(record => [
3590
+ ...record.reportedContext,
3591
+ record.toolResultMessage?.isError ? undefined : record.preparedContext,
3592
+ ]),
3593
+ );
3594
+ return {
3595
+ toolResults: emittedToolResults,
3596
+ ...(additionalContext !== undefined ? { additionalContext } : {}),
3597
+ };
3453
3598
  }
3454
3599
 
3455
3600
  /**
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
  }
@@ -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
  };
@@ -18,7 +18,7 @@
18
18
  * - `hasContextTokenUsage(usage)`: the report must carry usable context numbers.
19
19
  */
20
20
 
21
- import type { AssistantMessage } from "@oh-my-pi/pi-ai";
21
+ import type { AssistantMessage, Message } from "@oh-my-pi/pi-ai";
22
22
  import type { MessageCountOptions, Tokenizer } from "../tokenizer";
23
23
  import type { AgentMessage } from "../types";
24
24
  import { calculateContextTokens, hasContextTokenUsage } from "./compaction";
@@ -67,6 +67,36 @@ export function findTranscriptUsageAnchor(
67
67
  return undefined;
68
68
  }
69
69
 
70
+ /**
71
+ * Newest assistant turn in a provider request's `messages` whose usage still
72
+ * describes the prefix it sits on, or `undefined` when none does.
73
+ *
74
+ * Request contexts carry no compaction index, so staleness is read from the
75
+ * rewrite markers themselves: a compaction/branch summary or pruned tool result
76
+ * (`prunedAt`) replaced text that every report made at or before the rewrite
77
+ * already counted. A summary's rewrite time is its `timestamp` (commit time);
78
+ * its `historyRewriteAt` may be predated before a natively replayed retained
79
+ * tail so that tail's bound thinking survives, but the tail's usage still
80
+ * counted the summarized prefix.
81
+ */
82
+ export function findRequestUsageAnchor(messages: readonly Message[]): TranscriptUsageAnchor | undefined {
83
+ let rewriteAt = Number.NEGATIVE_INFINITY;
84
+ let anchorIndex = -1;
85
+ let anchor: AssistantMessage | undefined;
86
+ for (let index = 0; index < messages.length; index++) {
87
+ const message = messages[index];
88
+ if (message.role === "user" && message.historyRewriteAt !== undefined) {
89
+ rewriteAt = Math.max(rewriteAt, message.historyRewriteAt, message.timestamp);
90
+ } else if (message.role === "toolResult" && message.prunedAt !== undefined) {
91
+ rewriteAt = Math.max(rewriteAt, message.prunedAt);
92
+ } else if (isTranscriptUsageAnchor(message) && message.timestamp > rewriteAt) {
93
+ anchorIndex = index;
94
+ anchor = message;
95
+ }
96
+ }
97
+ return anchor && { index: anchorIndex, message: anchor, tokens: calculateContextTokens(anchor.usage) };
98
+ }
99
+
70
100
  /** Options for {@link estimateTranscriptTokens}. */
71
101
  export interface TranscriptTokenOptions {
72
102
  /**
package/src/index.ts CHANGED
@@ -6,6 +6,8 @@ export * from "./agent-loop";
6
6
  export * from "./append-only-context";
7
7
  // Compaction
8
8
  export * from "./compaction";
9
+ // Output cap sized to the remaining context window
10
+ export * from "./output-budget";
9
11
  // Process-global pause gate
10
12
  export * from "./pause";
11
13
  // Proxy utilities
@@ -22,6 +24,8 @@ export * from "./speculative-execution";
22
24
  export * from "./telemetry";
23
25
  // Thinking selectors
24
26
  export * from "./thinking";
27
+ // Tool-context augmentation
28
+ export * from "./tool-context";
25
29
  // Tokenizer choice
26
30
  export * from "./tokenizer";
27
31
  // Types