@sayknow-cli/agent-core 0.4.6 → 0.5.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,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
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.
15
+
16
+ ## [0.11.8] - 2026-07-23
17
+
18
+ ### Fixed
19
+
20
+ - Managed model fallback now accepts `reasoning_summary_start`, `reasoning_summary_delta`, and `reasoning_summary_end` assistant events instead of failing them as local snapshot errors.
5
21
  ## [0.11.3] - 2026-07-19
6
22
 
7
23
  ### Fixed
@@ -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": "@sayknow-cli/agent-core",
4
- "version": "0.4.6",
4
+ "version": "0.5.0",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://sayknow-cli.com",
7
7
  "author": "jaybeyond",
@@ -35,9 +35,9 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@sayknow-cli/ai": "0.4.6",
39
- "@sayknow-cli/natives": "0.4.6",
40
- "@sayknow-cli/utils": "0.4.6",
38
+ "@sayknow-cli/ai": "0.5.0",
39
+ "@sayknow-cli/natives": "0.5.0",
40
+ "@sayknow-cli/utils": "0.5.0",
41
41
  "@opentelemetry/api": "^1.9.0"
42
42
  },
43
43
  "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 "@sayknow-cli/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
@@ -644,14 +657,24 @@ function managedAssistantEventSnapshot(event: AssistantMessageEvent, message: As
644
657
  return contentIndex as number;
645
658
  };
646
659
  if (type === "start") return { type, partial: message };
647
- if (type === "text_start" || type === "thinking_start" || type === "toolcall_start")
660
+ if (
661
+ type === "text_start" ||
662
+ type === "thinking_start" ||
663
+ type === "reasoning_summary_start" ||
664
+ type === "toolcall_start"
665
+ )
648
666
  return { type, contentIndex: indexed(), partial: message };
649
- if (type === "text_delta" || type === "thinking_delta" || type === "toolcall_delta") {
667
+ if (
668
+ type === "text_delta" ||
669
+ type === "thinking_delta" ||
670
+ type === "reasoning_summary_delta" ||
671
+ type === "toolcall_delta"
672
+ ) {
650
673
  const delta = managedProperty(snapshot, "delta");
651
674
  if (typeof delta !== "string") throw new ManagedAttemptSnapshotError();
652
675
  return { type, contentIndex: indexed(), delta, partial: message };
653
676
  }
654
- if (type === "text_end" || type === "thinking_end") {
677
+ if (type === "text_end" || type === "thinking_end" || type === "reasoning_summary_end") {
655
678
  const content = managedProperty(snapshot, "content");
656
679
  if (typeof content !== "string") throw new ManagedAttemptSnapshotError();
657
680
  return { type, contentIndex: indexed(), content, partial: message };
@@ -1237,6 +1260,21 @@ async function runLoopBody(
1237
1260
  // Fires at most one repaired resend per run for the poisoned-history
1238
1261
  // `invalid_prompt` circuit breaker below.
1239
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;
1240
1278
 
1241
1279
  // Outer loop: continues when queued follow-up messages arrive after agent would stop
1242
1280
  while (true) {
@@ -1321,6 +1359,15 @@ async function runLoopBody(
1321
1359
  attemptTransaction.stageAssistantMessageEvent(partial, event),
1322
1360
  }
1323
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
+ }
1324
1371
  message = await streamAssistantResponse(
1325
1372
  currentContext,
1326
1373
  attemptConfig,
@@ -1331,6 +1378,9 @@ async function runLoopBody(
1331
1378
  stepCounter,
1332
1379
  streamFn,
1333
1380
  harmonyRetryAttempt,
1381
+ recoveryState.pending && recoveryState.syntheticMessage
1382
+ ? { syntheticMessage: recoveryState.syntheticMessage }
1383
+ : undefined,
1334
1384
  );
1335
1385
  const detection = detectHarmonyLeakInAssistantMessage(message);
1336
1386
  if (detection && shouldMitigateHarmonyLeak(config.model, detection)) {
@@ -1483,6 +1533,7 @@ async function runLoopBody(
1483
1533
  if (config.fallbackManaged && message.stopReason !== "error" && message.stopReason !== "aborted") {
1484
1534
  await config.onManagedAttemptAccepted?.();
1485
1535
  }
1536
+ const wasRecoveryAttempt = recoveryState.pending;
1486
1537
 
1487
1538
  if (message.stopReason === "error" || message.stopReason === "aborted") {
1488
1539
  // Create placeholder tool results for any tool calls in the aborted message
@@ -1517,26 +1568,67 @@ async function runLoopBody(
1517
1568
  hasMoreToolCalls = toolCalls.length > 0;
1518
1569
 
1519
1570
  const toolResults: ToolResultMessage[] = [];
1571
+ let repeatedMalformedToolCall = false;
1520
1572
  if (hasMoreToolCalls) {
1521
- const executionResult = await executeToolCalls(
1522
- currentContext,
1523
- message,
1524
- loopSignal,
1525
- stream,
1526
- config,
1527
- telemetry,
1528
- invokeAgentSpan,
1529
- );
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
+ );
1530
1600
 
1531
- toolResults.push(...executionResult.toolResults);
1532
- 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
+ }
1533
1618
 
1534
- for (const result of toolResults) {
1535
- currentContext.messages.push(result);
1536
- newMessages.push(result);
1619
+ for (const result of toolResults) {
1620
+ currentContext.messages.push(result);
1621
+ newMessages.push(result);
1622
+ }
1537
1623
  }
1538
1624
  }
1539
1625
 
1626
+ if (wasRecoveryAttempt) {
1627
+ recoveryState.pending = false;
1628
+ recoveryState.inserted = false;
1629
+ recoveryState.syntheticMessage = undefined;
1630
+ }
1631
+
1540
1632
  stream.push({ type: "turn_end", message, toolResults });
1541
1633
 
1542
1634
  if (steeringMessagesFromExecution && steeringMessagesFromExecution.length > 0) {
@@ -1550,6 +1642,27 @@ async function runLoopBody(
1550
1642
  stream.end(newMessages);
1551
1643
  return;
1552
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
+ }
1553
1666
  }
1554
1667
 
1555
1668
  // Agent would stop here. Check for follow-up messages.
@@ -1605,6 +1718,7 @@ async function streamAssistantResponse(
1605
1718
  stepCounter: StepCounter,
1606
1719
  streamFn?: StreamFn,
1607
1720
  harmonyRetryAttempt = 0,
1721
+ recoveryMode?: { syntheticMessage: UserMessage },
1608
1722
  ): Promise<AssistantMessage> {
1609
1723
  // Apply context transform if configured (AgentMessage[] → AgentMessage[])
1610
1724
  let messages = context.messages;
@@ -1629,7 +1743,24 @@ async function streamAssistantResponse(
1629
1743
  tools: normalizeTools(context.tools, !!config.intentTracing),
1630
1744
  };
1631
1745
  }
1632
-
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
+ }
1633
1764
  const streamFunction = streamFn || streamSimple;
1634
1765
 
1635
1766
  // Resolve API key (important for expiring tokens) — do this before resolving
@@ -1644,7 +1775,7 @@ async function streamAssistantResponse(
1644
1775
 
1645
1776
  const resolvedMetadata = config.metadataResolver ? config.metadataResolver(config.model.provider) : config.metadata;
1646
1777
 
1647
- const dynamicToolChoice = config.getToolChoice?.();
1778
+ const dynamicToolChoice = recoveryMode ? undefined : config.getToolChoice?.();
1648
1779
  const dynamicReasoning = config.getReasoning?.();
1649
1780
  const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model);
1650
1781
  const harmonyAbortController = harmonyMitigationEnabled ? new AbortController() : undefined;
@@ -1655,7 +1786,7 @@ async function streamAssistantResponse(
1655
1786
  : signal;
1656
1787
  const effectiveTemperature =
1657
1788
  harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature;
1658
- const effectiveToolChoice = dynamicToolChoice ?? config.toolChoice;
1789
+ const effectiveToolChoice = recoveryMode ? "none" : (dynamicToolChoice ?? config.toolChoice);
1659
1790
  const effectiveReasoning = dynamicReasoning ?? config.reasoning;
1660
1791
 
1661
1792
  const chatStepNumber = stepCounter.count;
@@ -1900,7 +2031,11 @@ async function executeToolCalls(
1900
2031
  config: AgentLoopConfig,
1901
2032
  telemetry: AgentTelemetry | undefined,
1902
2033
  invokeAgentSpan: Span | undefined,
1903
- ): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> {
2034
+ ): Promise<{
2035
+ toolResults: ToolResultMessage[];
2036
+ steeringMessages?: AgentMessage[];
2037
+ malformedToolCallSignatures: string[];
2038
+ }> {
1904
2039
  const tools = currentContext.tools;
1905
2040
  const {
1906
2041
  getSteeringMessages,
@@ -1941,6 +2076,7 @@ async function executeToolCalls(
1941
2076
  skipped: false,
1942
2077
  toolResultMessage: undefined as ToolResultMessage | undefined,
1943
2078
  resultEmitted: false,
2079
+ argumentValidationFailed: false,
1944
2080
  }));
1945
2081
 
1946
2082
  const checkSteering = async (): Promise<void> => {
@@ -2060,6 +2196,7 @@ async function executeToolCalls(
2060
2196
  await runInActiveSpan(toolSpan, async () => {
2061
2197
  try {
2062
2198
  if (toolCall.incompleteArguments) {
2199
+ record.argumentValidationFailed = true;
2063
2200
  // The provider flagged this call's argument JSON as truncated
2064
2201
  // (the model hit its output-token limit mid-call). Executing the
2065
2202
  // best-effort partial parse would run the tool on wrong input, so
@@ -2094,6 +2231,7 @@ async function executeToolCalls(
2094
2231
  if (tool.lenientArgValidation) {
2095
2232
  effectiveArgs = argsForExecution;
2096
2233
  } else {
2234
+ record.argumentValidationFailed = true;
2097
2235
  throw validationError;
2098
2236
  }
2099
2237
  }
@@ -2245,7 +2383,12 @@ async function executeToolCalls(
2245
2383
  }
2246
2384
  }
2247
2385
 
2248
- 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 };
2249
2392
  }
2250
2393
 
2251
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 "@sayknow-cli/ai/utils";
31
- import { $env, logger } from "@sayknow-cli/utils";
31
+ import { $credentialEnv, logger } from "@sayknow-cli/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.