@gajae-code/agent-core 0.12.8 → 0.12.11

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,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.12.11] - 2026-08-03
6
+
7
+ ## [0.12.10] - 2026-08-03
8
+
9
+ ### Fixed
10
+
11
+ - Composer repository-file shell policy rejections now receive one bounded, tool-enabled recovery turn without persisting the synthetic instruction. Generic loops retain their repository tools with `toolChoice: auto`; Cursor remote turns continue only when native tools did not already recover, queued user follow-ups take priority, and a second policy block terminates instead of looping. Existing malformed-tool recovery remains tool-free and does not consume dynamic tool-choice state.
12
+
5
13
  ## [0.12.8] - 2026-08-02
6
14
 
7
15
  ## [0.12.7] - 2026-07-31
@@ -1,4 +1,4 @@
1
- import type { AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream, Effort, ImageContent, Message, Model, SimpleStreamOptions, Static, streamSimple, TextContent, Tool, ToolChoice, ToolResultMessage, TransportFailureFacts, TSchema } from "@gajae-code/ai";
1
+ import type { AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream, Effort, ImageContent, Message, Model, SimpleStreamOptions, Static, streamSimple, TextContent, Tool, ToolChoice, ToolResultMessage, TransportFailureFacts, TSchema, UserMessage } from "@gajae-code/ai";
2
2
  import type { AppendOnlyContextManager } from "./append-only-context";
3
3
  import type { AttemptMinter, AttemptRunHandle, AttemptScope } from "./attempt-scope";
4
4
  import type { HarmonyAuditEvent } from "./harmony-leak";
@@ -292,6 +292,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
292
292
  * continues with another turn.
293
293
  */
294
294
  getFollowUpMessages?: () => Promise<AgentMessage[]>;
295
+ /**
296
+ * Supplies one bounded synthetic recovery instruction before the loop would
297
+ * otherwise yield. Unlike a follow-up, it is sent only to the provider and
298
+ * is not committed to durable agent message history.
299
+ */
300
+ getSyntheticRecoveryMessage?: () => Promise<UserMessage | undefined>;
295
301
  /**
296
302
  * Cooperative pause checkpoint evaluated at safe loop boundaries.
297
303
  *
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.8",
4
+ "version": "0.12.11",
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.8",
36
- "@gajae-code/natives": "0.12.8",
37
- "@gajae-code/utils": "0.12.8",
35
+ "@gajae-code/ai": "0.12.11",
36
+ "@gajae-code/natives": "0.12.11",
37
+ "@gajae-code/utils": "0.12.11",
38
38
  "@opentelemetry/api": "^1.9.0"
39
39
  },
40
40
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -20,6 +20,10 @@ import {
20
20
  validateToolArguments,
21
21
  zodToWireSchema,
22
22
  } from "@gajae-code/ai";
23
+ import {
24
+ COMPOSER_BASH_POLICY_RECOVERY_PROMPT,
25
+ isCurrentComposerBashPolicyBlockedError,
26
+ } from "@gajae-code/ai/providers/composer-discipline";
23
27
  import { isInvalidPromptError, neutralizeReservedControlTokens } from "@gajae-code/ai/utils";
24
28
  import { sanitizeText } from "@gajae-code/utils";
25
29
  import type { AttemptScope } from "./attempt-scope";
@@ -123,6 +127,15 @@ const standaloneOwnershipStates = new WeakMap<StandaloneRunOwnership, Standalone
123
127
  * bounded too.
124
128
  */
125
129
  const MAX_CONSECUTIVE_MALFORMED_TURNS = 5;
130
+
131
+ function isComposerBashPolicyBlockedToolResult(result: ToolResultMessage): boolean {
132
+ return (
133
+ result.isError &&
134
+ result.toolName === "bash" &&
135
+ result.content.some(content => content.type === "text" && isCurrentComposerBashPolicyBlockedError(content.text))
136
+ );
137
+ }
138
+
126
139
  function managedContextOverflow(message: AssistantMessage, config: AgentLoopConfig): boolean {
127
140
  const transportFailure = managedTransportFailure(message);
128
141
  // Managed empty-stop responses may be repaired by the managed shell below; only
@@ -150,12 +163,11 @@ function managedRetryableFailure(failure: unknown): boolean {
150
163
  const facts = managedTransportFailure(failure);
151
164
  if (!facts) return false;
152
165
  const trigger = classifyFallbackTrigger(facts);
153
- return (
154
- trigger.class === "rate_limit" ||
155
- trigger.class === "quota" ||
156
- trigger.class === "auth" ||
157
- trigger.class === "server"
158
- );
166
+ // A plain `forbidden` is terminal: retrying it just re-sends a request the
167
+ // caller is not authorized to make, and the credential-mutating consumers
168
+ // downstream would block healthy credentials on the way.
169
+ if (trigger.class === "auth") return trigger.authDisposition !== "forbidden";
170
+ return trigger.class === "rate_limit" || trigger.class === "quota" || trigger.class === "server";
159
171
  }
160
172
 
161
173
  /**
@@ -1407,12 +1419,16 @@ async function runLoopBody(
1407
1419
  // `invalid_prompt` circuit breaker below.
1408
1420
  let invalidPromptRepairAttempted = false;
1409
1421
  let previousMalformedToolSignatures = new Set<string>();
1410
- const recoveryState: {
1411
- pending: boolean;
1412
- inserted: boolean;
1413
- syntheticMessage?: UserMessage;
1414
- } = { pending: false, inserted: false };
1422
+ type SyntheticRecoveryKind = "malformed-tool-call" | "composer-bash-policy" | "provider";
1423
+ let pendingRecovery:
1424
+ | {
1425
+ kind: SyntheticRecoveryKind;
1426
+ inserted: boolean;
1427
+ syntheticMessage?: UserMessage;
1428
+ }
1429
+ | undefined;
1415
1430
  let malformedToolRecoveryAttempted = false;
1431
+ let composerBashPolicyRecoveryAttempted = false;
1416
1432
  // Deterministic terminal circuit breaker for argument-validation loops.
1417
1433
  //
1418
1434
  // Counts CONSECUTIVE turns whose tool calls were all malformed, regardless of
@@ -1507,6 +1523,8 @@ async function runLoopBody(
1507
1523
  let recovered: HarmonyRecoveredToolCall | undefined;
1508
1524
  let message: AssistantMessage;
1509
1525
  const attemptTransaction = transaction;
1526
+ const recoveryAttempt = pendingRecovery;
1527
+ const wasMalformedToolRecoveryAttempt = recoveryAttempt?.kind === "malformed-tool-call";
1510
1528
  try {
1511
1529
  const attemptConfig = attemptTransaction
1512
1530
  ? {
@@ -1515,14 +1533,22 @@ async function runLoopBody(
1515
1533
  attemptTransaction.stageAssistantMessageEvent(partial, event),
1516
1534
  }
1517
1535
  : config;
1518
- if (recoveryState.pending && !recoveryState.inserted) {
1519
- recoveryState.syntheticMessage = {
1520
- role: "user",
1521
- content: repeatedToolFailureRecoveryPrompt,
1522
- synthetic: true,
1523
- timestamp: Date.now(),
1524
- };
1525
- recoveryState.inserted = true;
1536
+ if (recoveryAttempt && !recoveryAttempt.inserted) {
1537
+ const recoveryContent =
1538
+ recoveryAttempt.kind === "composer-bash-policy"
1539
+ ? COMPOSER_BASH_POLICY_RECOVERY_PROMPT
1540
+ : recoveryAttempt.kind === "malformed-tool-call"
1541
+ ? repeatedToolFailureRecoveryPrompt
1542
+ : undefined;
1543
+ if (recoveryContent) {
1544
+ recoveryAttempt.syntheticMessage = {
1545
+ role: "user",
1546
+ content: recoveryContent,
1547
+ synthetic: true,
1548
+ timestamp: Date.now(),
1549
+ };
1550
+ }
1551
+ recoveryAttempt.inserted = true;
1526
1552
  }
1527
1553
  message = await streamAssistantResponse(
1528
1554
  currentContext,
@@ -1535,8 +1561,12 @@ async function runLoopBody(
1535
1561
  attemptScope,
1536
1562
  streamFn,
1537
1563
  harmonyRetryAttempt,
1538
- recoveryState.pending && recoveryState.syntheticMessage
1539
- ? { syntheticMessage: recoveryState.syntheticMessage }
1564
+ recoveryAttempt?.syntheticMessage
1565
+ ? {
1566
+ syntheticMessage: recoveryAttempt.syntheticMessage,
1567
+ disableTools: wasMalformedToolRecoveryAttempt,
1568
+ forceAutoToolChoice: !wasMalformedToolRecoveryAttempt,
1569
+ }
1540
1570
  : undefined,
1541
1571
  );
1542
1572
  const detection = detectHarmonyLeakInAssistantMessage(message);
@@ -1708,8 +1738,6 @@ async function runLoopBody(
1708
1738
  if (config.fallbackManaged && message.stopReason !== "error" && message.stopReason !== "aborted") {
1709
1739
  await config.onManagedAttemptAccepted?.();
1710
1740
  }
1711
- const wasRecoveryAttempt = recoveryState.pending;
1712
-
1713
1741
  if (message.stopReason === "error" || message.stopReason === "aborted") {
1714
1742
  // Create placeholder tool results for any tool calls in the aborted message
1715
1743
  // This maintains the tool_use/tool_result pairing that the API requires
@@ -1749,8 +1777,9 @@ async function runLoopBody(
1749
1777
 
1750
1778
  const toolResults: ToolResultMessage[] = [];
1751
1779
  let repeatedMalformedToolCall = false;
1780
+ let sawComposerBashPolicyBlock = false;
1752
1781
  if (hasMoreToolCalls) {
1753
- if (wasRecoveryAttempt) {
1782
+ if (wasMalformedToolRecoveryAttempt) {
1754
1783
  for (const toolCall of toolCalls) {
1755
1784
  const result = createAbortedToolResult(
1756
1785
  toolCall,
@@ -1781,6 +1810,7 @@ async function runLoopBody(
1781
1810
 
1782
1811
  toolResults.push(...executionResult.toolResults);
1783
1812
  steeringMessagesFromExecution = executionResult.steeringMessages;
1813
+ sawComposerBashPolicyBlock = executionResult.toolResults.some(isComposerBashPolicyBlockedToolResult);
1784
1814
 
1785
1815
  const malformedSignatures = executionResult.malformedToolCallSignatures;
1786
1816
  const allToolCallsMalformed =
@@ -1804,10 +1834,8 @@ async function runLoopBody(
1804
1834
  }
1805
1835
  }
1806
1836
 
1807
- if (wasRecoveryAttempt) {
1808
- recoveryState.pending = false;
1809
- recoveryState.inserted = false;
1810
- recoveryState.syntheticMessage = undefined;
1837
+ if (recoveryAttempt) {
1838
+ pendingRecovery = undefined;
1811
1839
  }
1812
1840
 
1813
1841
  stream.push({ type: "turn_end", message, toolResults, scope: attemptScope });
@@ -1828,10 +1856,26 @@ async function runLoopBody(
1828
1856
  stream.end(newMessages);
1829
1857
  return;
1830
1858
  }
1831
- if (repeatedMalformedToolCall && !malformedToolRecoveryAttempted) {
1832
- recoveryState.pending = true;
1833
- recoveryState.inserted = false;
1834
- recoveryState.syntheticMessage = undefined;
1859
+ if (sawComposerBashPolicyBlock && !composerBashPolicyRecoveryAttempted) {
1860
+ pendingRecovery = { kind: "composer-bash-policy", inserted: false };
1861
+ composerBashPolicyRecoveryAttempted = true;
1862
+ } else if (sawComposerBashPolicyBlock) {
1863
+ message.stopReason = "error";
1864
+ const recoveryLimitMessage =
1865
+ "Composer bash policy blocked repository file I/O again after its one automatic recovery turn. Continue with dedicated repository tools.";
1866
+ message.errorMessage = message.errorMessage
1867
+ ? `${message.errorMessage} | ${recoveryLimitMessage}`
1868
+ : recoveryLimitMessage;
1869
+ publishAgentEnd(
1870
+ stream,
1871
+ config,
1872
+ buildAgentEndEvent(newMessages, telemetry, stepCounter.count, "completed", attemptScope),
1873
+ attemptScope,
1874
+ );
1875
+ stream.end(newMessages);
1876
+ return;
1877
+ } else if (repeatedMalformedToolCall && !malformedToolRecoveryAttempted) {
1878
+ pendingRecovery = { kind: "malformed-tool-call", inserted: false };
1835
1879
  malformedToolRecoveryAttempted = true;
1836
1880
  } else if (consecutiveMalformedTurns >= MAX_CONSECUTIVE_MALFORMED_TURNS) {
1837
1881
  // Deterministic terminal circuit breaker. The one-shot recovery turn
@@ -1868,12 +1912,23 @@ async function runLoopBody(
1868
1912
  stream.end(newMessages);
1869
1913
  return;
1870
1914
  }
1915
+ // Poll the consume-on-read recovery candidate before follow-ups so a real
1916
+ // user message can supersede it without letting the stale recovery resurface
1917
+ // after that follow-up turn.
1918
+ const syntheticRecoveryMessage = await config.getSyntheticRecoveryMessage?.();
1871
1919
  const followUpMessages = (await config.getFollowUpMessages?.()) || [];
1872
1920
  if (followUpMessages.length > 0) {
1873
1921
  // Set as pending so inner loop processes them
1874
1922
  pendingMessages = followUpMessages;
1875
1923
  continue;
1876
1924
  }
1925
+ if (syntheticRecoveryMessage) {
1926
+ // Provider-side tool protocols (such as Cursor) can finish their remote
1927
+ // turn after a local policy rejection. Continue once without committing
1928
+ // the recovery instruction to durable history.
1929
+ pendingRecovery = { kind: "provider", inserted: true, syntheticMessage: syntheticRecoveryMessage };
1930
+ continue;
1931
+ }
1877
1932
 
1878
1933
  // No more messages, exit
1879
1934
  break;
@@ -1920,7 +1975,11 @@ async function streamAssistantResponse(
1920
1975
  scope?: AttemptScope,
1921
1976
  streamFn?: StreamFn,
1922
1977
  harmonyRetryAttempt = 0,
1923
- recoveryMode?: { syntheticMessage: UserMessage },
1978
+ recoveryMode?: {
1979
+ syntheticMessage: UserMessage;
1980
+ disableTools?: boolean;
1981
+ forceAutoToolChoice?: boolean;
1982
+ },
1924
1983
  ): Promise<AssistantMessage> {
1925
1984
  // Apply context transform if configured (AgentMessage[] → AgentMessage[])
1926
1985
  let messages = context.messages;
@@ -1951,7 +2010,11 @@ async function streamAssistantResponse(
1951
2010
  await config.convertToLlm([recoveryMode.syntheticMessage]),
1952
2011
  config.model,
1953
2012
  );
1954
- llmContext = { ...llmContext, messages: [...llmContext.messages, ...syntheticMessages], tools: [] };
2013
+ llmContext = {
2014
+ ...llmContext,
2015
+ messages: [...llmContext.messages, ...syntheticMessages],
2016
+ tools: recoveryMode.disableTools ? [] : llmContext.tools,
2017
+ };
1955
2018
  } else {
1956
2019
  llmContext = {
1957
2020
  ...llmContext,
@@ -1959,7 +2022,7 @@ async function streamAssistantResponse(
1959
2022
  await config.convertToLlm([...messages, recoveryMode.syntheticMessage]),
1960
2023
  config.model,
1961
2024
  ),
1962
- tools: [],
2025
+ tools: recoveryMode.disableTools ? [] : llmContext.tools,
1963
2026
  };
1964
2027
  }
1965
2028
  }
@@ -1977,6 +2040,8 @@ async function streamAssistantResponse(
1977
2040
 
1978
2041
  const resolvedMetadata = config.metadataResolver ? config.metadataResolver(config.model.provider) : config.metadata;
1979
2042
 
2043
+ // Synthetic recovery requests choose their tool mode explicitly below and
2044
+ // must never consume a queued dynamic choice intended for an ordinary turn.
1980
2045
  const dynamicToolChoice = recoveryMode ? undefined : config.getToolChoice?.();
1981
2046
  const dynamicReasoning = config.getReasoning?.();
1982
2047
  const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model);
@@ -1994,7 +2059,11 @@ async function streamAssistantResponse(
1994
2059
  : AbortSignal.any(requestSignals);
1995
2060
  const effectiveTemperature =
1996
2061
  harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature;
1997
- const effectiveToolChoice = recoveryMode ? "none" : (dynamicToolChoice ?? config.toolChoice);
2062
+ const effectiveToolChoice = recoveryMode?.disableTools
2063
+ ? "none"
2064
+ : recoveryMode?.forceAutoToolChoice
2065
+ ? "auto"
2066
+ : (dynamicToolChoice ?? config.toolChoice);
1998
2067
  const effectiveReasoning = dynamicReasoning ?? config.reasoning;
1999
2068
 
2000
2069
  const chatStepNumber = stepCounter.count;
@@ -2633,7 +2702,9 @@ async function executeToolCalls(
2633
2702
  caughtError = e;
2634
2703
  result = {
2635
2704
  content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
2636
- details: {},
2705
+ details: {
2706
+ failureKind: record.argumentValidationFailed ? "argument_validation" : "execution",
2707
+ },
2637
2708
  };
2638
2709
  isError = true;
2639
2710
  }
package/src/agent.ts CHANGED
@@ -20,6 +20,10 @@ import {
20
20
  type ToolChoice,
21
21
  type ToolResultMessage,
22
22
  } from "@gajae-code/ai";
23
+ import {
24
+ CURSOR_COMPOSER_BASH_POLICY_RECOVERY_PROMPT,
25
+ isCurrentComposerBashPolicyBlockedError,
26
+ } from "@gajae-code/ai/providers/composer-discipline";
23
27
  import { extractHttpStatusFromError } from "@gajae-code/utils";
24
28
  import { agentLoop, agentLoopContinue } from "./agent-loop";
25
29
  import type { AppendOnlyContextManager } from "./append-only-context";
@@ -67,6 +71,20 @@ function assertUserImagePlaceholdersHavePayload(messages: readonly AgentMessage[
67
71
  }
68
72
  }
69
73
 
74
+ const CURSOR_NATIVE_REPOSITORY_RECOVERY_TOOL_NAMES = new Set(["read", "grep", "search", "find", "write", "delete"]);
75
+
76
+ function isCursorComposerBashPolicyBlockedResult(message: ToolResultMessage): boolean {
77
+ return (
78
+ message.isError &&
79
+ message.toolName === "bash" &&
80
+ message.content.some(content => content.type === "text" && isCurrentComposerBashPolicyBlockedError(content.text))
81
+ );
82
+ }
83
+
84
+ function isSuccessfulCursorNativeRepositoryToolResult(message: ToolResultMessage): boolean {
85
+ return message.isError !== true && CURSOR_NATIVE_REPOSITORY_RECOVERY_TOOL_NAMES.has(message.toolName);
86
+ }
87
+
70
88
  /**
71
89
  * Whether persisted history ends at a point where a new model turn can resume.
72
90
  * Assistant-ended histories require an in-memory queued message and are handled
@@ -1488,6 +1506,11 @@ export class Agent {
1488
1506
  messages: this.#state.messages.slice(),
1489
1507
  tools: this.#state.tools,
1490
1508
  };
1509
+ // Cursor can execute native tools inside one remote turn, then return
1510
+ // `turnEnded` without another model request. Remember a Composer policy
1511
+ // rejection until the loop reaches that safe continuation boundary.
1512
+ let cursorComposerBashRecoveryPending = false;
1513
+ let cursorComposerBashRecoveryAttempted = false;
1491
1514
 
1492
1515
  const cursorOnToolResult =
1493
1516
  !fallbackManaged && (this.#cursorExecHandlers || this.#cursorOnToolResult)
@@ -1507,6 +1530,16 @@ export class Agent {
1507
1530
  }
1508
1531
  } catch {}
1509
1532
  }
1533
+ if (isCursorComposerBashPolicyBlockedResult(finalMessage)) {
1534
+ cursorComposerBashRecoveryPending = true;
1535
+ } else if (
1536
+ cursorComposerBashRecoveryPending &&
1537
+ isSuccessfulCursorNativeRepositoryToolResult(finalMessage)
1538
+ ) {
1539
+ // The same remote turn already replanned through a native tool,
1540
+ // so do not create a redundant local continuation afterward.
1541
+ cursorComposerBashRecoveryPending = false;
1542
+ }
1510
1543
  // Cursor executes tools server-side during streaming, so the assistant message
1511
1544
  // already incorporates results. We buffer here and emit in correct order
1512
1545
  // when the assistant message ends.
@@ -1652,6 +1685,23 @@ export class Agent {
1652
1685
  }
1653
1686
  return queued;
1654
1687
  },
1688
+ getSyntheticRecoveryMessage: async () => {
1689
+ if (
1690
+ this.#activeRunId !== runId ||
1691
+ !cursorComposerBashRecoveryPending ||
1692
+ cursorComposerBashRecoveryAttempted
1693
+ ) {
1694
+ return undefined;
1695
+ }
1696
+ cursorComposerBashRecoveryPending = false;
1697
+ cursorComposerBashRecoveryAttempted = true;
1698
+ return {
1699
+ role: "user",
1700
+ content: CURSOR_COMPOSER_BASH_POLICY_RECOVERY_PROMPT,
1701
+ synthetic: true,
1702
+ timestamp: Date.now(),
1703
+ };
1704
+ },
1655
1705
  onBeforeYield: async () => {
1656
1706
  if (this.#activeRunId !== runId) return;
1657
1707
  await this.#onBeforeYield?.();
package/src/types.ts CHANGED
@@ -15,6 +15,7 @@ import type {
15
15
  ToolResultMessage,
16
16
  TransportFailureFacts,
17
17
  TSchema,
18
+ UserMessage,
18
19
  } from "@gajae-code/ai";
19
20
  import type { AppendOnlyContextManager } from "./append-only-context";
20
21
  import type { AttemptMinter, AttemptRunHandle, AttemptScope } from "./attempt-scope";
@@ -319,6 +320,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
319
320
  * continues with another turn.
320
321
  */
321
322
  getFollowUpMessages?: () => Promise<AgentMessage[]>;
323
+ /**
324
+ * Supplies one bounded synthetic recovery instruction before the loop would
325
+ * otherwise yield. Unlike a follow-up, it is sent only to the provider and
326
+ * is not committed to durable agent message history.
327
+ */
328
+ getSyntheticRecoveryMessage?: () => Promise<UserMessage | undefined>;
322
329
  /**
323
330
  * Cooperative pause checkpoint evaluated at safe loop boundaries.
324
331
  *