@gajae-code/agent-core 0.11.10 → 0.12.0

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,7 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
- ## [0.11.10] - 2026-07-25
5
+ ## [0.12.0] - 2026-07-28
6
+
7
+ ## [0.11.11] - 2026-07-26
8
+
9
+ ### Fixed
10
+
11
+ - Managed runs now release their logical-run ownership before terminal observers are notified, so terminal overflow recovery cannot leave a stale owner behind.
12
+ - The OpenAI remote-compaction endpoint is now resolved from trusted environment sources only. `OPENAI_BASE_URL` was read through the merged view that includes the caller's `cwd/.env`, so a repository could redirect compaction requests that carry the OpenAI credential; it now uses the non-project resolver, leaving shell and user-level configuration unchanged.
13
+ - Repeated malformed tool calls now get one tool-free recovery response, preventing argument-validation loops from ending without an answer while leaving ordinary execution-error retries unchanged. The recovery turn commits its assistant to the durable context, forces `toolChoice: "none"` alongside an empty tool list without consuming a queued tool choice, and never executes a tool call it did not advertise. Its recovery prompt is request-only, so append-only tool prefixes stay stable and the durable message log is unchanged.
14
+ - Argument-validation loops now reach a deterministic terminal state. If a model keeps emitting only malformed tool calls after the one-shot recovery turn, the run stops with an explanatory error instead of calling the provider indefinitely. The bound counts consecutive all-malformed turns rather than repeated argument signatures, so a model rotating invalid argument shapes is bounded too; any healthy tool turn resets it.
6
15
 
7
16
  ## [0.11.8] - 2026-07-23
8
17
 
@@ -39,6 +39,8 @@ export interface RemoteCompactionResponse {
39
39
  shortSummary?: string;
40
40
  }
41
41
  export declare function shouldUseOpenAiRemoteCompaction(model: Model): boolean;
42
+ /** Test seam: the compaction endpoint as resolved from trusted env. */
43
+ export declare function resolveOpenAiCompactEndpointForTest(model: Model, authCredentialType?: "api_key" | "oauth"): string;
42
44
  export declare function getPreservedOpenAiRemoteCompactionData(preserveData: Record<string, unknown> | undefined): OpenAiRemoteCompactionPreserveData | undefined;
43
45
  export declare function withOpenAiRemoteCompactionPreserveData(preserveData: Record<string, unknown> | undefined, remoteCompaction: OpenAiRemoteCompactionPreserveData | undefined): Record<string, unknown> | undefined;
44
46
  export declare function estimateOpenAiCompactInputTokens(input: Array<Record<string, unknown>>, instructions: string): number;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/agent-core",
4
- "version": "0.11.10",
4
+ "version": "0.12.0",
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.11.10",
36
- "@gajae-code/natives": "0.11.10",
37
- "@gajae-code/utils": "0.11.10",
35
+ "@gajae-code/ai": "0.12.0",
36
+ "@gajae-code/natives": "0.12.0",
37
+ "@gajae-code/utils": "0.12.0",
38
38
  "@opentelemetry/api": "^1.9.0"
39
39
  },
40
40
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  type ToolResultMessage,
17
17
  type TSchema,
18
18
  transportFailureFacts,
19
+ type UserMessage,
19
20
  validateToolArguments,
20
21
  zodToWireSchema,
21
22
  } from "@gajae-code/ai";
@@ -32,6 +33,7 @@ import {
32
33
  shouldMitigateHarmonyLeak,
33
34
  signalListLabel,
34
35
  } from "./harmony-leak";
36
+ import repeatedToolFailureRecoveryPrompt from "./prompts/repeated-tool-failure-recovery.md" with { type: "text" };
35
37
  import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
36
38
  import {
37
39
  type AgentTelemetry,
@@ -100,6 +102,17 @@ class ManagedAttemptSnapshotError extends Error {
100
102
  const managedAttemptTextEncoder = new TextEncoder();
101
103
 
102
104
  const ABORTED: unique symbol = Symbol("agent-loop-aborted");
105
+
106
+ /**
107
+ * Terminal bound for argument-validation loops: how many CONSECUTIVE turns may
108
+ * consist entirely of malformed tool calls before the run stops.
109
+ *
110
+ * The one-shot tools-free recovery turn fires first; this is the deterministic
111
+ * backstop for a model that keeps emitting unusable calls after it. Counted per
112
+ * turn rather than per argument signature so a model rotating invalid shapes is
113
+ * bounded too.
114
+ */
115
+ const MAX_CONSECUTIVE_MALFORMED_TURNS = 5;
103
116
  function managedContextOverflow(message: AssistantMessage, config: AgentLoopConfig): boolean {
104
117
  const transportFailure = managedTransportFailure(message);
105
118
  // Managed empty-stop responses may be repaired by the managed shell below; only
@@ -1247,6 +1260,21 @@ async function runLoopBody(
1247
1260
  // Fires at most one repaired resend per run for the poisoned-history
1248
1261
  // `invalid_prompt` circuit breaker below.
1249
1262
  let invalidPromptRepairAttempted = false;
1263
+ let previousMalformedToolSignatures = new Set<string>();
1264
+ const recoveryState: {
1265
+ pending: boolean;
1266
+ inserted: boolean;
1267
+ syntheticMessage?: UserMessage;
1268
+ } = { pending: false, inserted: false };
1269
+ let malformedToolRecoveryAttempted = false;
1270
+ // Deterministic terminal circuit breaker for argument-validation loops.
1271
+ //
1272
+ // Counts CONSECUTIVE turns whose tool calls were all malformed, regardless of
1273
+ // whether the arguments repeat. Signature-based "repeated" detection alone is
1274
+ // not a bound: a model that rotates invalid argument shapes never trips it, so
1275
+ // the loop could run forever. Any turn that produces a non-malformed batch
1276
+ // resets the counter, so healthy runs are unaffected.
1277
+ let consecutiveMalformedTurns = 0;
1250
1278
 
1251
1279
  // Outer loop: continues when queued follow-up messages arrive after agent would stop
1252
1280
  while (true) {
@@ -1331,6 +1359,15 @@ async function runLoopBody(
1331
1359
  attemptTransaction.stageAssistantMessageEvent(partial, event),
1332
1360
  }
1333
1361
  : config;
1362
+ if (recoveryState.pending && !recoveryState.inserted) {
1363
+ recoveryState.syntheticMessage = {
1364
+ role: "user",
1365
+ content: repeatedToolFailureRecoveryPrompt,
1366
+ synthetic: true,
1367
+ timestamp: Date.now(),
1368
+ };
1369
+ recoveryState.inserted = true;
1370
+ }
1334
1371
  message = await streamAssistantResponse(
1335
1372
  currentContext,
1336
1373
  attemptConfig,
@@ -1341,6 +1378,9 @@ async function runLoopBody(
1341
1378
  stepCounter,
1342
1379
  streamFn,
1343
1380
  harmonyRetryAttempt,
1381
+ recoveryState.pending && recoveryState.syntheticMessage
1382
+ ? { syntheticMessage: recoveryState.syntheticMessage }
1383
+ : undefined,
1344
1384
  );
1345
1385
  const detection = detectHarmonyLeakInAssistantMessage(message);
1346
1386
  if (detection && shouldMitigateHarmonyLeak(config.model, detection)) {
@@ -1493,6 +1533,7 @@ async function runLoopBody(
1493
1533
  if (config.fallbackManaged && message.stopReason !== "error" && message.stopReason !== "aborted") {
1494
1534
  await config.onManagedAttemptAccepted?.();
1495
1535
  }
1536
+ const wasRecoveryAttempt = recoveryState.pending;
1496
1537
 
1497
1538
  if (message.stopReason === "error" || message.stopReason === "aborted") {
1498
1539
  // Create placeholder tool results for any tool calls in the aborted message
@@ -1527,26 +1568,67 @@ async function runLoopBody(
1527
1568
  hasMoreToolCalls = toolCalls.length > 0;
1528
1569
 
1529
1570
  const toolResults: ToolResultMessage[] = [];
1571
+ let repeatedMalformedToolCall = false;
1530
1572
  if (hasMoreToolCalls) {
1531
- const executionResult = await executeToolCalls(
1532
- currentContext,
1533
- message,
1534
- loopSignal,
1535
- stream,
1536
- config,
1537
- telemetry,
1538
- invokeAgentSpan,
1539
- );
1573
+ if (wasRecoveryAttempt) {
1574
+ for (const toolCall of toolCalls) {
1575
+ const result = createAbortedToolResult(
1576
+ toolCall,
1577
+ stream,
1578
+ "error",
1579
+ "Tool calls are disabled during repeated malformed tool-call recovery.",
1580
+ );
1581
+ currentContext.messages.push(result);
1582
+ newMessages.push(result);
1583
+ toolResults.push(result);
1584
+ recordSkippedTool(telemetry, {
1585
+ toolCallId: toolCall.id,
1586
+ toolName: toolCall.name,
1587
+ status: "skipped",
1588
+ });
1589
+ }
1590
+ } else {
1591
+ const executionResult = await executeToolCalls(
1592
+ currentContext,
1593
+ message,
1594
+ loopSignal,
1595
+ stream,
1596
+ config,
1597
+ telemetry,
1598
+ invokeAgentSpan,
1599
+ );
1540
1600
 
1541
- toolResults.push(...executionResult.toolResults);
1542
- steeringMessagesFromExecution = executionResult.steeringMessages;
1601
+ toolResults.push(...executionResult.toolResults);
1602
+ steeringMessagesFromExecution = executionResult.steeringMessages;
1603
+
1604
+ const malformedSignatures = executionResult.malformedToolCallSignatures;
1605
+ const allToolCallsMalformed =
1606
+ toolResults.length > 0 && malformedSignatures.length === toolResults.length;
1607
+ if (allToolCallsMalformed) {
1608
+ consecutiveMalformedTurns += 1;
1609
+ const uniqueMalformedSignatures = new Set(malformedSignatures);
1610
+ repeatedMalformedToolCall =
1611
+ uniqueMalformedSignatures.size < malformedSignatures.length ||
1612
+ [...uniqueMalformedSignatures].some(signature => previousMalformedToolSignatures.has(signature));
1613
+ previousMalformedToolSignatures = uniqueMalformedSignatures;
1614
+ } else {
1615
+ consecutiveMalformedTurns = 0;
1616
+ previousMalformedToolSignatures = new Set();
1617
+ }
1543
1618
 
1544
- for (const result of toolResults) {
1545
- currentContext.messages.push(result);
1546
- newMessages.push(result);
1619
+ for (const result of toolResults) {
1620
+ currentContext.messages.push(result);
1621
+ newMessages.push(result);
1622
+ }
1547
1623
  }
1548
1624
  }
1549
1625
 
1626
+ if (wasRecoveryAttempt) {
1627
+ recoveryState.pending = false;
1628
+ recoveryState.inserted = false;
1629
+ recoveryState.syntheticMessage = undefined;
1630
+ }
1631
+
1550
1632
  stream.push({ type: "turn_end", message, toolResults });
1551
1633
 
1552
1634
  if (steeringMessagesFromExecution && steeringMessagesFromExecution.length > 0) {
@@ -1560,6 +1642,27 @@ async function runLoopBody(
1560
1642
  stream.end(newMessages);
1561
1643
  return;
1562
1644
  }
1645
+ if (repeatedMalformedToolCall && !malformedToolRecoveryAttempted) {
1646
+ recoveryState.pending = true;
1647
+ recoveryState.inserted = false;
1648
+ recoveryState.syntheticMessage = undefined;
1649
+ malformedToolRecoveryAttempted = true;
1650
+ } else if (consecutiveMalformedTurns >= MAX_CONSECUTIVE_MALFORMED_TURNS) {
1651
+ // Deterministic terminal circuit breaker. The one-shot recovery turn
1652
+ // above already had its chance; if the model is still emitting only
1653
+ // malformed tool calls after it, the run cannot make progress and must
1654
+ // stop rather than burn the provider budget. Terminates on consecutive
1655
+ // count, not argument signatures, so rotating invalid shapes are bounded
1656
+ // too.
1657
+ message.stopReason = "error";
1658
+ const breakerMessage = `Stopping after ${consecutiveMalformedTurns} consecutive turns of malformed tool calls; the model did not produce a usable tool call or answer.`;
1659
+ message.errorMessage = message.errorMessage
1660
+ ? `${message.errorMessage} | ${breakerMessage}`
1661
+ : breakerMessage;
1662
+ stream.push(buildAgentEndEvent(newMessages, telemetry, stepCounter.count));
1663
+ stream.end(newMessages);
1664
+ return;
1665
+ }
1563
1666
  }
1564
1667
 
1565
1668
  // Agent would stop here. Check for follow-up messages.
@@ -1615,6 +1718,7 @@ async function streamAssistantResponse(
1615
1718
  stepCounter: StepCounter,
1616
1719
  streamFn?: StreamFn,
1617
1720
  harmonyRetryAttempt = 0,
1721
+ recoveryMode?: { syntheticMessage: UserMessage },
1618
1722
  ): Promise<AssistantMessage> {
1619
1723
  // Apply context transform if configured (AgentMessage[] → AgentMessage[])
1620
1724
  let messages = context.messages;
@@ -1639,7 +1743,24 @@ async function streamAssistantResponse(
1639
1743
  tools: normalizeTools(context.tools, !!config.intentTracing),
1640
1744
  };
1641
1745
  }
1642
-
1746
+ if (recoveryMode) {
1747
+ if (config.appendOnlyContext) {
1748
+ const syntheticMessages = normalizeMessagesForProvider(
1749
+ await config.convertToLlm([recoveryMode.syntheticMessage]),
1750
+ config.model,
1751
+ );
1752
+ llmContext = { ...llmContext, messages: [...llmContext.messages, ...syntheticMessages], tools: [] };
1753
+ } else {
1754
+ llmContext = {
1755
+ ...llmContext,
1756
+ messages: normalizeMessagesForProvider(
1757
+ await config.convertToLlm([...messages, recoveryMode.syntheticMessage]),
1758
+ config.model,
1759
+ ),
1760
+ tools: [],
1761
+ };
1762
+ }
1763
+ }
1643
1764
  const streamFunction = streamFn || streamSimple;
1644
1765
 
1645
1766
  // Resolve API key (important for expiring tokens) — do this before resolving
@@ -1654,7 +1775,7 @@ async function streamAssistantResponse(
1654
1775
 
1655
1776
  const resolvedMetadata = config.metadataResolver ? config.metadataResolver(config.model.provider) : config.metadata;
1656
1777
 
1657
- const dynamicToolChoice = config.getToolChoice?.();
1778
+ const dynamicToolChoice = recoveryMode ? undefined : config.getToolChoice?.();
1658
1779
  const dynamicReasoning = config.getReasoning?.();
1659
1780
  const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model);
1660
1781
  const harmonyAbortController = harmonyMitigationEnabled ? new AbortController() : undefined;
@@ -1665,7 +1786,7 @@ async function streamAssistantResponse(
1665
1786
  : signal;
1666
1787
  const effectiveTemperature =
1667
1788
  harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature;
1668
- const effectiveToolChoice = dynamicToolChoice ?? config.toolChoice;
1789
+ const effectiveToolChoice = recoveryMode ? "none" : (dynamicToolChoice ?? config.toolChoice);
1669
1790
  const effectiveReasoning = dynamicReasoning ?? config.reasoning;
1670
1791
 
1671
1792
  const chatStepNumber = stepCounter.count;
@@ -1910,7 +2031,11 @@ async function executeToolCalls(
1910
2031
  config: AgentLoopConfig,
1911
2032
  telemetry: AgentTelemetry | undefined,
1912
2033
  invokeAgentSpan: Span | undefined,
1913
- ): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> {
2034
+ ): Promise<{
2035
+ toolResults: ToolResultMessage[];
2036
+ steeringMessages?: AgentMessage[];
2037
+ malformedToolCallSignatures: string[];
2038
+ }> {
1914
2039
  const tools = currentContext.tools;
1915
2040
  const {
1916
2041
  getSteeringMessages,
@@ -1951,6 +2076,7 @@ async function executeToolCalls(
1951
2076
  skipped: false,
1952
2077
  toolResultMessage: undefined as ToolResultMessage | undefined,
1953
2078
  resultEmitted: false,
2079
+ argumentValidationFailed: false,
1954
2080
  }));
1955
2081
 
1956
2082
  const checkSteering = async (): Promise<void> => {
@@ -2070,6 +2196,7 @@ async function executeToolCalls(
2070
2196
  await runInActiveSpan(toolSpan, async () => {
2071
2197
  try {
2072
2198
  if (toolCall.incompleteArguments) {
2199
+ record.argumentValidationFailed = true;
2073
2200
  // The provider flagged this call's argument JSON as truncated
2074
2201
  // (the model hit its output-token limit mid-call). Executing the
2075
2202
  // best-effort partial parse would run the tool on wrong input, so
@@ -2104,6 +2231,7 @@ async function executeToolCalls(
2104
2231
  if (tool.lenientArgValidation) {
2105
2232
  effectiveArgs = argsForExecution;
2106
2233
  } else {
2234
+ record.argumentValidationFailed = true;
2107
2235
  throw validationError;
2108
2236
  }
2109
2237
  }
@@ -2255,7 +2383,12 @@ async function executeToolCalls(
2255
2383
  }
2256
2384
  }
2257
2385
 
2258
- return { toolResults: emittedToolResults, steeringMessages };
2386
+ const malformedToolCallSignatures = records.flatMap(record =>
2387
+ record.argumentValidationFailed && record.toolResultMessage?.isError
2388
+ ? [`${record.toolCall.name}:${JSON.stringify(record.toolCall.arguments)}`]
2389
+ : [],
2390
+ );
2391
+ return { toolResults: emittedToolResults, steeringMessages, malformedToolCallSignatures };
2259
2392
  }
2260
2393
 
2261
2394
  /**
package/src/agent.ts CHANGED
@@ -1196,6 +1196,9 @@ export class Agent {
1196
1196
  */
1197
1197
  requestRunTerminal(logicalRunId: ManagedLogicalRunId, request: RunTerminalRequest): boolean {
1198
1198
  if (this.#terminalizedLogicalRunIds.has(logicalRunId)) return false;
1199
+ if (this.#managedLogicalRunOwner === logicalRunId) {
1200
+ this.#managedLogicalRunOwner = undefined;
1201
+ }
1199
1202
  this.#finalizeRun(
1200
1203
  logicalRunId,
1201
1204
  {
@@ -28,7 +28,7 @@ import {
28
28
  neutralizeResponsesInputControlTokens,
29
29
  normalizeResponsesToolCallId,
30
30
  } from "@gajae-code/ai/utils";
31
- import { $env, logger } from "@gajae-code/utils";
31
+ import { $credentialEnv, logger } from "@gajae-code/utils";
32
32
 
33
33
  const OPENAI_DEFAULT_BASE_URL = "https://api.openai.com/v1";
34
34
 
@@ -81,7 +81,8 @@ function resolveOpenAiCompactEndpoint(model: Model, authCredentialType?: "api_ke
81
81
  return resolveOpenAiCodexCompactEndpoint(model.baseUrl);
82
82
  }
83
83
 
84
- const envBaseUrl = $env.OPENAI_BASE_URL?.trim();
84
+ // Trusted sources only: the compaction endpoint carries the OpenAI credential.
85
+ const envBaseUrl = $credentialEnv("OPENAI_BASE_URL");
85
86
  const configuredBaseUrl = model.baseUrl?.trim();
86
87
  const rawBase =
87
88
  authCredentialType === "oauth"
@@ -94,6 +95,11 @@ function resolveOpenAiCompactEndpoint(model: Model, authCredentialType?: "api_ke
94
95
  return `${normalizedBase}/v1/responses/compact`;
95
96
  }
96
97
 
98
+ /** Test seam: the compaction endpoint as resolved from trusted env. */
99
+ export function resolveOpenAiCompactEndpointForTest(model: Model, authCredentialType?: "api_key" | "oauth"): string {
100
+ return resolveOpenAiCompactEndpoint(model, authCredentialType);
101
+ }
102
+
97
103
  function resolveOpenAiCodexCompactEndpoint(baseUrl: string | undefined): string {
98
104
  const rawBase = baseUrl && baseUrl.length > 0 ? baseUrl : CODEX_BASE_URL;
99
105
  const normalizedBase = rawBase.endsWith("/") ? rawBase.slice(0, -1) : rawBase;
@@ -0,0 +1 @@
1
+ The immediately preceding tool calls failed because their arguments were malformed. Do not call any tools. Answer the original user request now using the conversation and any successful tool results already available. If the evidence is incomplete, state that limitation instead of returning an empty response.