@gajae-code/agent-core 0.12.11 → 0.12.13

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,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.12.13] - 2026-08-06
6
+
7
+ ### Fixed
8
+
9
+ - An aborted run whose tool ignores its `AbortSignal` now terminates on its own (#3894). `Promise.allSettled` waited on the unresolved call forever, so the turn only ended when the session's force-abort budget expired; the loop now emits a synthetic aborted result for the outstanding calls and `waitForIdle` settles immediately. Session dispose consequently reaches idle through the cooperative path instead of force-invalidating the run.
10
+
11
+ ## [0.12.12] - 2026-08-05
12
+
13
+ ### Fixed
14
+
15
+ - DeepSeek-family reasoning-content replay 400s are now retryable via a bounded, strip-only circuit breaker. When a proxy strips the encrypted reasoning blob to an empty `encrypted_content`, DeepSeek rejects every follow-up turn with "The `reasoning_content` in the thinking mode must be passed back to the API." Resending the identical history re-triggers this deterministic 400, so the agent loop now strips the unusable `reasoning` items from the Responses history payload in place and resends exactly once (mirroring the `invalid_prompt` poisoned-history breaker). Non-reasoning items are preserved; fail-fast when nothing can be stripped. Budget = one repaired resend.
16
+
5
17
  ## [0.12.11] - 2026-08-03
6
18
 
7
19
  ## [0.12.10] - 2026-08-03
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/agent-core",
4
- "version": "0.12.11",
4
+ "version": "0.12.13",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -32,9 +32,9 @@
32
32
  "fmt": "biome format --write ."
33
33
  },
34
34
  "dependencies": {
35
- "@gajae-code/ai": "0.12.11",
36
- "@gajae-code/natives": "0.12.11",
37
- "@gajae-code/utils": "0.12.11",
35
+ "@gajae-code/ai": "0.12.13",
36
+ "@gajae-code/natives": "0.12.13",
37
+ "@gajae-code/utils": "0.12.13",
38
38
  "@opentelemetry/api": "^1.9.0"
39
39
  },
40
40
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -24,7 +24,12 @@ import {
24
24
  COMPOSER_BASH_POLICY_RECOVERY_PROMPT,
25
25
  isCurrentComposerBashPolicyBlockedError,
26
26
  } from "@gajae-code/ai/providers/composer-discipline";
27
- import { isInvalidPromptError, neutralizeReservedControlTokens } from "@gajae-code/ai/utils";
27
+ import {
28
+ isInvalidPromptError,
29
+ isReasoningContentReplayError,
30
+ neutralizeReservedControlTokens,
31
+ stripUnusableReasoningItems,
32
+ } from "@gajae-code/ai/utils";
28
33
  import { sanitizeText } from "@gajae-code/utils";
29
34
  import type { AttemptScope } from "./attempt-scope";
30
35
  import {
@@ -202,6 +207,31 @@ function repairInvalidPromptHistory(messages: AgentMessage[]): boolean {
202
207
  }
203
208
  return changed;
204
209
  }
210
+ /**
211
+ * Strip Responses-API `reasoning` items whose `encrypted_content` a proxy
212
+ * emptied, in-place across the outgoing history's `providerPayload`. DeepSeek in
213
+ * thinking mode rejects replay of reasoning whose encrypted blob was stripped,
214
+ * so dropping those items lets the model re-reason instead of re-triggering a
215
+ * deterministic 400 ("reasoning_content ... must be passed back to the API").
216
+ * Only the opaque Responses history payload is mutated; durable message content
217
+ * and ordering are preserved. Returns whether any item was actually removed —
218
+ * the circuit breaker uses this to decide between a single repaired resend
219
+ * (removed) and immediate fail-fast (unchanged).
220
+ */
221
+ function repairReasoningContentReplayHistory(messages: AgentMessage[]): boolean {
222
+ let removed = 0;
223
+ for (const message of messages) {
224
+ const payload = (message as { providerPayload?: { type?: string; items?: Array<Record<string, unknown>> } })
225
+ .providerPayload;
226
+ if (payload?.type !== "openaiResponsesHistory" || !Array.isArray(payload.items)) continue;
227
+ const { result, removed: count } = stripUnusableReasoningItems(payload.items);
228
+ if (count > 0) {
229
+ payload.items = result;
230
+ removed += count;
231
+ }
232
+ }
233
+ return removed > 0;
234
+ }
205
235
 
206
236
  function managedFailureOutcome(message: AssistantMessage, scope?: AttemptScope): ManagedAttemptOutcome {
207
237
  return {
@@ -1418,6 +1448,9 @@ async function runLoopBody(
1418
1448
  // Fires at most one repaired resend per run for the poisoned-history
1419
1449
  // `invalid_prompt` circuit breaker below.
1420
1450
  let invalidPromptRepairAttempted = false;
1451
+ // Fires at most one repaired resend per run for the reasoning-content replay
1452
+ // breaker below (DeepSeek "reasoning_content ... must be passed back").
1453
+ let reasoningContentRepairAttempted = false;
1421
1454
  let previousMalformedToolSignatures = new Set<string>();
1422
1455
  type SyntheticRecoveryKind = "malformed-tool-call" | "composer-bash-policy" | "provider";
1423
1456
  let pendingRecovery:
@@ -1680,6 +1713,40 @@ async function runLoopBody(
1680
1713
  continue;
1681
1714
  }
1682
1715
  }
1716
+ // Session-level reasoning-content replay circuit breaker (bounded,
1717
+ // strip-only). DeepSeek V4 (and reasoning-capable siblings on any
1718
+ // OpenAI-compatible proxy) reject every follow-up turn with
1719
+ // "reasoning_content ... must be passed back to the API" once a prior
1720
+ // assistant turn carried reasoning the proxy stripped to an empty
1721
+ // `encrypted_content`. Resending the identical history re-triggers the
1722
+ // deterministic 400, so naive auto-retry would loop. On the first such
1723
+ // rejection of this run, strip the unusable `reasoning` items from the
1724
+ // Responses history payload IN PLACE (never dropping text, tool-call, or
1725
+ // tool-output items). If that removed anything, resend exactly once so
1726
+ // the model re-reasons; if nothing could be stripped, fall through to
1727
+ // terminal handling and fail fast. Budget = one repaired resend.
1728
+ if (
1729
+ !config.fallbackManaged &&
1730
+ message.stopReason === "error" &&
1731
+ !reasoningContentRepairAttempted &&
1732
+ isReasoningContentReplayError(message)
1733
+ ) {
1734
+ reasoningContentRepairAttempted = true;
1735
+ // The rejected turn was already committed to the context by the
1736
+ // streaming path. Repair (and resend) only the history that
1737
+ // preceded it: replaying an errored assistant turn re-triggers the
1738
+ // rejection and leaves a second assistant tail behind.
1739
+ const rejectedIndex = currentContext.messages.length - 1;
1740
+ const rejectedCommitted =
1741
+ rejectedIndex >= 0 && currentContext.messages[rejectedIndex]?.role === "assistant";
1742
+ const retained = rejectedCommitted
1743
+ ? currentContext.messages.slice(0, rejectedIndex)
1744
+ : currentContext.messages;
1745
+ if (repairReasoningContentReplayHistory(retained)) {
1746
+ if (rejectedCommitted) currentContext.messages.splice(rejectedIndex, 1);
1747
+ continue;
1748
+ }
1749
+ }
1683
1750
 
1684
1751
  const overflow = managedContextOverflow(message, config);
1685
1752
  if (config.fallbackManaged && overflow) {
@@ -2828,7 +2895,26 @@ async function executeToolCalls(
2828
2895
  }
2829
2896
  }
2830
2897
 
2831
- await Promise.allSettled(tasks);
2898
+ const allTasks = Promise.allSettled(tasks);
2899
+ if (!signal) {
2900
+ await allTasks;
2901
+ } else {
2902
+ const abortPromise = Promise.withResolvers<boolean>();
2903
+ const onAbort = () => abortPromise.resolve(true);
2904
+ signal.addEventListener("abort", onAbort, { once: true });
2905
+ try {
2906
+ const aborted = signal.aborted || (await Promise.race([allTasks.then(() => false), abortPromise.promise]));
2907
+ if (aborted) {
2908
+ for (const record of records) {
2909
+ if (record.toolResultMessage) continue;
2910
+ record.skipped = true;
2911
+ emitToolResult(record, createAbortedToolExecutionResult(), true);
2912
+ }
2913
+ }
2914
+ } finally {
2915
+ signal.removeEventListener("abort", onAbort);
2916
+ }
2917
+ }
2832
2918
 
2833
2919
  for (const record of records) {
2834
2920
  if (!record.toolResultMessage) {
@@ -2903,3 +2989,9 @@ function createSkippedToolResult(): AgentToolResult<any> {
2903
2989
  details: {},
2904
2990
  };
2905
2991
  }
2992
+ function createAbortedToolExecutionResult(): AgentToolResult<any> {
2993
+ return {
2994
+ content: [{ type: "text", text: "Tool execution was aborted." }],
2995
+ details: {},
2996
+ };
2997
+ }