@gajae-code/agent-core 0.11.10 → 0.11.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 +6 -1
- package/package.json +4 -4
- package/src/agent-loop.ts +152 -19
- package/src/prompts/repeated-tool-failure-recovery.md +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
-
## [0.11.
|
|
5
|
+
## [0.11.11] - 2026-07-26
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
- 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
11
|
|
|
7
12
|
## [0.11.8] - 2026-07-23
|
|
8
13
|
|
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.
|
|
4
|
+
"version": "0.11.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.11.
|
|
36
|
-
"@gajae-code/natives": "0.11.
|
|
37
|
-
"@gajae-code/utils": "0.11.
|
|
35
|
+
"@gajae-code/ai": "0.11.11",
|
|
36
|
+
"@gajae-code/natives": "0.11.11",
|
|
37
|
+
"@gajae-code/utils": "0.11.11",
|
|
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
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
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
|
-
|
|
1542
|
-
|
|
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
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
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<{
|
|
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
|
-
|
|
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
|
/**
|
|
@@ -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.
|