@oh-my-pi/pi-agent-core 18.3.1 → 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/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.3.2] - 2026-09-25
6
+
7
+ ### Fixed
8
+
9
+ - Fixed tool calls that put their payload in the intent field `i` (for example a file body in `write`) silently running with the leftover arguments; they now fail with an error telling the model to retry ([#13140](https://github.com/can1357/oh-my-pi/issues/13140), [#13141](https://github.com/can1357/oh-my-pi/pull/13141) by [@radkawar](https://github.com/radkawar))
10
+ - Fixed the Anthropic compaction failure log omitting why no compaction block came back; it now names the stop reason ([#13300](https://github.com/can1357/oh-my-pi/pull/13300) by [@alphastorm](https://github.com/alphastorm))
11
+
5
12
  ## [18.3.1] - 2026-09-25
6
13
 
7
14
  ### Added
@@ -82,6 +82,15 @@ export interface SupersedePruneConfig {
82
82
  /** Tool-result protection matchers (same contract as {@link PruneConfig.protectedTools}). */
83
83
  protectedTools: ProtectedToolMatcher[];
84
84
  }
85
+ /**
86
+ * Generic age-based pruning floor. Below this, blanking a result to
87
+ * `[Output truncated - N tokens]` recovers nothing — the placeholder itself
88
+ * costs ~8 tokens, so a sub-floor result grows the context (and churns the
89
+ * prompt cache) instead of shrinking it. Superseded/useless results keep their
90
+ * own rules: useless already drops no-savings candidates, superseded prunes for
91
+ * correctness regardless of size.
92
+ */
93
+ export declare const MIN_PRUNE_TOKENS = 50;
85
94
  /**
86
95
  * Prune superseded tool results (e.g. stale `read` outputs replaced by a newer
87
96
  * read of the same file) and, when `pruneUseless` is set, results their tool
@@ -37,6 +37,14 @@ export interface TranscriptUsageAnchor {
37
37
  * the session-entry walkers.
38
38
  */
39
39
  export declare function isTranscriptUsageAnchor(message: AgentMessage): message is AssistantMessage;
40
+ /** Options for {@link findTranscriptUsageAnchor}. */
41
+ export interface TranscriptUsageAnchorOptions {
42
+ /**
43
+ * Also treat usage reported at or before the newest `prunedAt` as stale,
44
+ * the same rule {@link findRequestUsageAnchor} applies to request contexts.
45
+ */
46
+ skipPrunedAnchors?: boolean;
47
+ }
40
48
  /**
41
49
  * Newest assistant turn in `messages[fromIndex..]` whose usage can anchor the
42
50
  * transcript, or `undefined` when none qualifies (fresh context, or every
@@ -45,7 +53,7 @@ export declare function isTranscriptUsageAnchor(message: AgentMessage): message
45
53
  * `fromIndex` excludes turns whose usage is stale — anything a compaction
46
54
  * summarized away describes a prompt that is no longer sent.
47
55
  */
48
- export declare function findTranscriptUsageAnchor(messages: readonly AgentMessage[], fromIndex?: number): TranscriptUsageAnchor | undefined;
56
+ export declare function findTranscriptUsageAnchor(messages: readonly AgentMessage[], fromIndex?: number, options?: TranscriptUsageAnchorOptions): TranscriptUsageAnchor | undefined;
49
57
  /**
50
58
  * Newest assistant turn in a provider request's `messages` whose usage still
51
59
  * describes the prefix it sits on, or `undefined` when none does.
@@ -67,6 +75,8 @@ export interface TranscriptTokenOptions {
67
75
  * accounting is governed separately by {@link countFromIndex}.
68
76
  */
69
77
  anchorFromIndex?: number;
78
+ /** Forwarded to {@link findTranscriptUsageAnchor}. */
79
+ skipPrunedAnchors?: boolean;
70
80
  /**
71
81
  * First message whose content is counted locally when no anchor is found.
72
82
  * Defaults to 0 (count the whole transcript), which is what a floor
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-agent-core",
4
- "version": "18.3.1",
4
+ "version": "18.3.2",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -38,16 +38,16 @@
38
38
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
39
39
  },
40
40
  "dependencies": {
41
- "@oh-my-pi/pi-ai": "18.3.1",
42
- "@oh-my-pi/pi-catalog": "18.3.1",
43
- "@oh-my-pi/pi-natives": "18.3.1",
44
- "@oh-my-pi/pi-utils": "18.3.1",
45
- "@oh-my-pi/pi-wire": "18.3.1",
46
- "@oh-my-pi/snapcompact": "18.3.1",
41
+ "@oh-my-pi/pi-ai": "18.3.2",
42
+ "@oh-my-pi/pi-catalog": "18.3.2",
43
+ "@oh-my-pi/pi-natives": "18.3.2",
44
+ "@oh-my-pi/pi-utils": "18.3.2",
45
+ "@oh-my-pi/pi-wire": "18.3.2",
46
+ "@oh-my-pi/snapcompact": "18.3.2",
47
47
  "@opentelemetry/api": "^1.9.1"
48
48
  },
49
49
  "devDependencies": {
50
- "@oh-my-pi/omptype": "18.3.1",
50
+ "@oh-my-pi/omptype": "18.3.2",
51
51
  "@opentelemetry/context-async-hooks": "^2.9.0",
52
52
  "@opentelemetry/sdk-trace-base": "^2.9.0",
53
53
  "@types/bun": "^1.3.14"
package/src/agent-loop.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  getStreamingPartialJson,
40
40
  kCursorExecResolved,
41
41
  } from "@oh-my-pi/pi-ai/utils/block-symbols";
42
+ import { schemaDefinesProperty } from "@oh-my-pi/pi-ai/utils/schema/json-schema-validator";
42
43
  import { stamp } from "@oh-my-pi/pi-ai/utils/schema/stamps";
43
44
  import {
44
45
  createHarmonyAuditEvent,
@@ -1022,6 +1023,13 @@ function resolveIntentMode(intent: AgentTool["intent"]): "require" | "optional"
1022
1023
  return "require";
1023
1024
  }
1024
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
+
1025
1033
  function extractIntent(args: Record<string, unknown>): { intent?: string; strippedArgs: Record<string, unknown> } {
1026
1034
  const { [INTENT_FIELD]: intent, ...strippedArgs } = args;
1027
1035
  if (typeof intent !== "string") {
@@ -2830,6 +2838,19 @@ async function prepareToolCallDispatch(
2830
2838
  if (intentTracing) {
2831
2839
  const { intent, strippedArgs } = extractIntent(toolCall.arguments);
2832
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
+ }
2833
2854
  if (intent) {
2834
2855
  toolCall.intent = intent;
2835
2856
  } else if (typeof tool?.intent === "function") {
@@ -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 {
@@ -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;
@@ -47,6 +47,31 @@ export function isTranscriptUsageAnchor(message: AgentMessage): message is Assis
47
47
  return assistant.usage !== undefined && hasContextTokenUsage(assistant.usage);
48
48
  }
49
49
 
50
+ /**
51
+ * Newest `prunedAt` in `messages`, or `-Infinity` when nothing was pruned.
52
+ *
53
+ * A pruned tool result was rewritten in place, so every usage report made at
54
+ * or before this time still counts the removed bytes.
55
+ */
56
+ function newestPrunedAt(messages: readonly AgentMessage[]): number {
57
+ let rewriteAt = Number.NEGATIVE_INFINITY;
58
+ for (const message of messages) {
59
+ if (message.role === "toolResult" && message.prunedAt !== undefined) {
60
+ rewriteAt = Math.max(rewriteAt, message.prunedAt);
61
+ }
62
+ }
63
+ return rewriteAt;
64
+ }
65
+
66
+ /** Options for {@link findTranscriptUsageAnchor}. */
67
+ export interface TranscriptUsageAnchorOptions {
68
+ /**
69
+ * Also treat usage reported at or before the newest `prunedAt` as stale,
70
+ * the same rule {@link findRequestUsageAnchor} applies to request contexts.
71
+ */
72
+ skipPrunedAnchors?: boolean;
73
+ }
74
+
50
75
  /**
51
76
  * Newest assistant turn in `messages[fromIndex..]` whose usage can anchor the
52
77
  * transcript, or `undefined` when none qualifies (fresh context, or every
@@ -58,10 +83,12 @@ export function isTranscriptUsageAnchor(message: AgentMessage): message is Assis
58
83
  export function findTranscriptUsageAnchor(
59
84
  messages: readonly AgentMessage[],
60
85
  fromIndex = 0,
86
+ options?: TranscriptUsageAnchorOptions,
61
87
  ): TranscriptUsageAnchor | undefined {
88
+ const rewriteAt = options?.skipPrunedAnchors === true ? newestPrunedAt(messages) : Number.NEGATIVE_INFINITY;
62
89
  for (let index = messages.length - 1; index >= fromIndex; index--) {
63
90
  const message = messages[index];
64
- if (!isTranscriptUsageAnchor(message)) continue;
91
+ if (!isTranscriptUsageAnchor(message) || message.timestamp <= rewriteAt) continue;
65
92
  return { index, message, tokens: calculateContextTokens(message.usage) };
66
93
  }
67
94
  return undefined;
@@ -105,6 +132,8 @@ export interface TranscriptTokenOptions {
105
132
  * accounting is governed separately by {@link countFromIndex}.
106
133
  */
107
134
  anchorFromIndex?: number;
135
+ /** Forwarded to {@link findTranscriptUsageAnchor}. */
136
+ skipPrunedAnchors?: boolean;
108
137
  /**
109
138
  * First message whose content is counted locally when no anchor is found.
110
139
  * Defaults to 0 (count the whole transcript), which is what a floor
@@ -132,7 +161,9 @@ export function estimateTranscriptTokens(
132
161
  ): number {
133
162
  const estimateOptions: MessageCountOptions | undefined =
134
163
  options?.excludeEncryptedReasoning === true ? { excludeEncryptedReasoning: true } : undefined;
135
- const anchor = findTranscriptUsageAnchor(messages, options?.anchorFromIndex ?? 0);
164
+ const anchor = findTranscriptUsageAnchor(messages, options?.anchorFromIndex ?? 0, {
165
+ skipPrunedAnchors: options?.skipPrunedAnchors,
166
+ });
136
167
  let total = anchor?.tokens ?? 0;
137
168
  for (let index = anchor ? anchor.index + 1 : (options?.countFromIndex ?? 0); index < messages.length; index++) {
138
169
  total += tokenizer.countMessage(messages[index], estimateOptions);