@sayknow-cli/agent-core 0.3.12 → 0.3.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,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.10.0] - 2026-07-12
6
+
7
+ ### Fixed
8
+
9
+ - The native-free token heuristic is now script-aware: common-BMP CJK characters (Hangul, unified/compat Han, Kana, CJK punctuation, full-width forms) are charged at 1 token each (measured o200k_base upper bound 0.96 tokens/char) and supplementary code points (surrogate pairs: rare Han extensions, emoji) at 1 token per code point, instead of chars/4 for everything. The old estimate undercounted Korean/CJK-heavy unsent context by 2–4x and could delay threshold compaction past the provider window; ASCII estimates are unchanged. `boundConversationTextForSummary` now derives its truncation cut from the text's own estimated token density, validates the complete assembled excerpt (elision marker included) against the estimator, and fails closed — bare marker only when the marker itself fits the budget, otherwise an empty excerpt, including when the computed input budget is non-positive — instead of assuming 4 chars/token and returning over-budget or unbounded text.
10
+
11
+ - A tool call for a name absent from the active tool set now appends a recovery hint pointing at `search_tool_bm25` (gated on a callable `search_tool_bm25`, matched by internal name or `customWireName`), so a model no longer abandons a discoverable tool such as `task` after a bare "Tool <name> not found"; the base error wording stays byte-for-byte stable when discovery is unavailable (#2042).
12
+
5
13
  ## [0.9.2] - 2026-07-09
6
14
 
7
15
  ### Fixed
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sayknow-cli/agent-core",
4
- "version": "0.3.12",
4
+ "version": "0.3.13",
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",
@@ -25,7 +25,7 @@
25
25
  "state-management"
26
26
  ],
27
27
  "main": "./src/index.ts",
28
- "types": "./dist/types/index.d.ts",
28
+ "types": "./src/index.ts",
29
29
  "scripts": {
30
30
  "check": "biome check . && bun run check:types",
31
31
  "check:types": "tsgo -p tsconfig.json --noEmit",
@@ -35,9 +35,9 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@sayknow-cli/ai": "0.3.12",
39
- "@sayknow-cli/natives": "0.3.12",
40
- "@sayknow-cli/utils": "0.3.12",
38
+ "@sayknow-cli/ai": "0.3.13",
39
+ "@sayknow-cli/natives": "0.3.13",
40
+ "@sayknow-cli/utils": "0.3.13",
41
41
  "@opentelemetry/api": "^1.9.0"
42
42
  },
43
43
  "devDependencies": {
@@ -51,24 +51,23 @@
51
51
  "files": [
52
52
  "src",
53
53
  "README.md",
54
- "CHANGELOG.md",
55
- "dist/types"
54
+ "CHANGELOG.md"
56
55
  ],
57
56
  "exports": {
58
57
  ".": {
59
- "types": "./dist/types/index.d.ts",
58
+ "types": "./src/index.ts",
60
59
  "import": "./src/index.ts"
61
60
  },
62
61
  "./compaction": {
63
- "types": "./dist/types/compaction.d.ts",
62
+ "types": "./src/compaction.ts",
64
63
  "import": "./src/compaction.ts"
65
64
  },
66
65
  "./compaction/*": {
67
- "types": "./dist/types/compaction/*.d.ts",
66
+ "types": "./src/compaction/*.ts",
68
67
  "import": "./src/compaction/*.ts"
69
68
  },
70
69
  "./*": {
71
- "types": "./dist/types/*.d.ts",
70
+ "types": "./src/*.ts",
72
71
  "import": "./src/*.ts"
73
72
  }
74
73
  }
package/src/agent-loop.ts CHANGED
@@ -1092,6 +1092,17 @@ function emitAbortedAssistantMessage(
1092
1092
  return abortedMessage;
1093
1093
  }
1094
1094
 
1095
+ /**
1096
+ * Match a tool against the model-visible call name. Tools emitted via OpenAI's
1097
+ * custom-tool path (e.g. `apply_patch` on GPT-5) arrive under their wire-level
1098
+ * name, which may differ from the harness-internal `name`, so dispatch and any
1099
+ * "is this tool callable" check must consider both. Internal `name` takes
1100
+ * precedence when a caller needs a single match.
1101
+ */
1102
+ function toolMatchesCallName(tool: { name: string; customWireName?: string }, callName: string): boolean {
1103
+ return tool.name === callName || (tool.customWireName !== undefined && tool.customWireName === callName);
1104
+ }
1105
+
1095
1106
  /**
1096
1107
  * Execute tool calls from an assistant message.
1097
1108
  */
@@ -1273,7 +1284,22 @@ async function executeToolCalls(
1273
1284
  `Re-issue the call with complete arguments, splitting the work into smaller steps if needed.`,
1274
1285
  );
1275
1286
  }
1276
- if (!tool) throw new Error(`Tool ${toolCall.name} not found`);
1287
+ if (!tool) {
1288
+ // A discoverable tool that hasn't been activated yet resolves to
1289
+ // undefined here. The model often "remembers" such a tool (e.g.
1290
+ // `task`) from earlier context and calls it by name without first
1291
+ // re-discovering it. Point it at tool discovery so it can activate
1292
+ // the tool and retry instead of giving up on the capability. The
1293
+ // base wording stays byte-for-byte stable for downstream consumers;
1294
+ // the period and hint are appended only when discovery is callable.
1295
+ const base = `Tool ${toolCall.name} not found`;
1296
+ const hasToolDiscovery = tools?.some(t => toolMatchesCallName(t, "search_tool_bm25")) ?? false;
1297
+ throw new Error(
1298
+ hasToolDiscovery
1299
+ ? `${base}. If you are unsure whether this tool exists or how to use it, call \`search_tool_bm25\` to discover and activate the matching tool, then retry.`
1300
+ : base,
1301
+ );
1302
+ }
1277
1303
 
1278
1304
  let effectiveArgs: Record<string, unknown>;
1279
1305
  try {
package/src/agent.ts CHANGED
@@ -53,6 +53,16 @@ function assertUserImagePlaceholdersHavePayload(messages: readonly AgentMessage[
53
53
  }
54
54
  }
55
55
 
56
+ /**
57
+ * Whether persisted history ends at a point where a new model turn can resume.
58
+ * Assistant-ended histories require an in-memory queued message and are handled
59
+ * separately by `Agent.continue()`.
60
+ */
61
+ export function canContinuePersistedHistory(messages: readonly AgentMessage[]): boolean {
62
+ const lastMessage = messages.at(-1);
63
+ return lastMessage !== undefined && lastMessage.role !== "assistant";
64
+ }
65
+
56
66
  /**
57
67
  * Default convertToLlm: Keep only LLM-compatible messages, convert attachments.
58
68
  */
@@ -1171,6 +1181,10 @@ export class Agent {
1171
1181
  throw new Error("Cannot continue from message role: assistant");
1172
1182
  }
1173
1183
 
1184
+ if (!canContinuePersistedHistory(messages)) {
1185
+ throw new Error("No messages to continue from");
1186
+ }
1187
+
1174
1188
  await this.#runLoop(undefined);
1175
1189
  }
1176
1190
 
@@ -405,7 +405,61 @@ function countCollectedMessageFragments(collected: { fragments: string[]; extra:
405
405
  const HEURISTIC_BYTES_PER_TOKEN = 4;
406
406
 
407
407
  /**
408
- * Native-free chars/4 token estimate for a message. This is the only message
408
+ * Token-dense character weight for the script-aware heuristic.
409
+ *
410
+ * Common-BMP CJK blocks (Hangul, unified/compat Han, Kana, CJK punctuation,
411
+ * full-width forms) tokenize at ~0.6–1.0 tokens per character under
412
+ * o200k-class BPE vocabularies (measured o200k_base: Hangul prose 0.604,
413
+ * spaceless Hangul 0.964, Han 0.793, Kana 0.740 tokens/char — versus the
414
+ * 0.25 the chars/4 heuristic assumes). Each such character is charged 1
415
+ * token: an upper bound for these measured blocks whose only failure mode is
416
+ * compacting slightly early, while undercounting risks overflowing the
417
+ * provider window.
418
+ *
419
+ * Surrogate code units are charged 0.5 each, i.e. 1 token per supplementary
420
+ * code point (supplementary Han extensions, emoji, and other astral chars).
421
+ * That is a floor rather than an upper bound — rare ideographs and emoji can
422
+ * cost several tokens — but it is strictly safer than the 0.5-per-pair the
423
+ * plain chars/4 rule produced.
424
+ */
425
+ function tokenDenseCharWeight(text: string): { weight: number; units: number } {
426
+ let weight = 0;
427
+ let units = 0;
428
+ for (let i = 0; i < text.length; i++) {
429
+ const c = text.charCodeAt(i);
430
+ if (
431
+ (c >= 0x1100 && c <= 0x11ff) || // Hangul Jamo
432
+ (c >= 0x3000 && c <= 0x303f) || // CJK symbols & punctuation
433
+ (c >= 0x3040 && c <= 0x30ff) || // Hiragana & Katakana
434
+ (c >= 0x3130 && c <= 0x318f) || // Hangul compatibility Jamo
435
+ (c >= 0x3400 && c <= 0x4dbf) || // CJK ideographs extension A
436
+ (c >= 0x4e00 && c <= 0x9fff) || // CJK unified ideographs
437
+ (c >= 0xac00 && c <= 0xd7af) || // Hangul syllables
438
+ (c >= 0xf900 && c <= 0xfaff) || // CJK compatibility ideographs
439
+ (c >= 0xff00 && c <= 0xffef) // Half/full-width forms
440
+ ) {
441
+ weight += 1;
442
+ units += 1;
443
+ } else if (c >= 0xd800 && c <= 0xdfff) {
444
+ // Surrogate half: a supplementary code point contributes two units.
445
+ weight += 0.5;
446
+ units += 1;
447
+ }
448
+ }
449
+ return { weight, units };
450
+ }
451
+
452
+ /**
453
+ * Script-aware native-free token estimate for a plain string fragment:
454
+ * token-dense characters cost ~1 token each, everything else chars/4.
455
+ */
456
+ function estimateFragmentTokensHeuristic(fragment: string): { dense: number; otherChars: number } {
457
+ const { weight, units } = tokenDenseCharWeight(fragment);
458
+ return { dense: weight, otherChars: fragment.length - units };
459
+ }
460
+
461
+ /**
462
+ * Native-free token estimate for a message. This is the only message
409
463
  * token estimator: provider usage (see {@link calculatePromptTokens}) anchors
410
464
  * the already-sent context, and this covers unsent/trailing deltas, per-entry
411
465
  * budgeting, and display surfaces. Callers add a conservative inflation factor
@@ -413,24 +467,23 @@ const HEURISTIC_BYTES_PER_TOKEN = 4;
413
467
  */
414
468
  export function estimateMessageTokensHeuristic(message: AgentMessage): number {
415
469
  const { fragments, extra } = collectMessageFragments(message);
416
- let bytes = 0;
417
- for (const fragment of fragments) {
418
- bytes += fragment.length;
419
- }
420
- return extra + Math.ceil(bytes / HEURISTIC_BYTES_PER_TOKEN);
470
+ return extra + estimateTextTokensHeuristic(fragments);
421
471
  }
422
472
 
423
473
  /**
424
- * Native-free chars/4 token estimate for plain string fragments. Fragment-level
425
- * counterpart of {@link estimateMessageTokensHeuristic}.
474
+ * Script-aware native-free token estimate for plain string fragments.
475
+ * Fragment-level counterpart of {@link estimateMessageTokensHeuristic}.
426
476
  */
427
477
  export function estimateTextTokensHeuristic(fragments: string | readonly string[]): number {
428
- if (typeof fragments === "string") return Math.ceil(fragments.length / HEURISTIC_BYTES_PER_TOKEN);
429
- let bytes = 0;
430
- for (const fragment of fragments) {
431
- bytes += fragment.length;
478
+ const list = typeof fragments === "string" ? [fragments] : fragments;
479
+ let dense = 0;
480
+ let otherChars = 0;
481
+ for (const fragment of list) {
482
+ const counts = estimateFragmentTokensHeuristic(fragment);
483
+ dense += counts.dense;
484
+ otherChars += counts.otherChars;
432
485
  }
433
- return Math.ceil(bytes / HEURISTIC_BYTES_PER_TOKEN);
486
+ return Math.ceil(dense + Math.max(0, otherChars) / HEURISTIC_BYTES_PER_TOKEN);
434
487
  }
435
488
 
436
489
  /** Shared content walk for both the native and heuristic estimators. */
@@ -760,8 +813,8 @@ export interface SummaryOptions {
760
813
  * on the very overflow the recovery was meant to absorb.
761
814
  *
762
815
  * The budget reserves the summary's own output tokens plus prompt/system/template
763
- * overhead, and applies a conservative safety factor because the chars/4 heuristic
764
- * undercounts dense or CJK text (the reason the original overflow was missed).
816
+ * overhead, and applies a conservative safety factor for estimator error on
817
+ * dense text (the reason the original overflow was missed).
765
818
  * Truncation keeps the head (origin/goals) and the tail (most recent state) and
766
819
  * elides the middle; it is a last resort that only triggers when the input would
767
820
  * otherwise not fit.
@@ -779,16 +832,43 @@ export function boundConversationTextForSummary(
779
832
  const inputBudgetTokens = Math.floor(
780
833
  (contextWindow - Math.max(0, outputMaxTokens) - OVERHEAD_TOKENS) * SAFETY_FACTOR,
781
834
  );
782
- if (inputBudgetTokens <= 0) return conversationText;
783
- if (estimateTextTokensHeuristic(conversationText) <= inputBudgetTokens) return conversationText;
784
-
785
- const budgetChars = inputBudgetTokens * HEURISTIC_BYTES_PER_TOKEN;
786
- const headChars = Math.floor(budgetChars * 0.35);
787
- const tailChars = Math.max(0, budgetChars - headChars);
788
- const head = conversationText.slice(0, headChars);
789
- const tail = tailChars > 0 ? conversationText.slice(conversationText.length - tailChars) : "";
790
- const elided = conversationText.length - head.length - tail.length;
791
- return `${head}\n\n[... ${elided} characters of older conversation elided so this summarization request fits within the model context window ...]\n\n${tail}`;
835
+ const totalEstimatedTokens = estimateTextTokensHeuristic(conversationText);
836
+ const assemble = (head: string, tail: string): string => {
837
+ const elided = conversationText.length - head.length - tail.length;
838
+ return `${head}\n\n[... ${elided} characters of older conversation elided so this summarization request fits within the model context window ...]\n\n${tail}`;
839
+ };
840
+ const bareMarker = assemble("", "");
841
+ const fitsBudget = (candidate: string) => estimateTextTokensHeuristic(candidate) <= inputBudgetTokens;
842
+ if (inputBudgetTokens <= 0) {
843
+ // A window this small cannot fit any excerpt (not even the marker).
844
+ // Fail closed with an empty excerpt rather than submitting text into a
845
+ // request that is guaranteed to overflow.
846
+ return "";
847
+ }
848
+ if (totalEstimatedTokens <= inputBudgetTokens) return conversationText;
849
+
850
+ // Derive the character budget from the text's own measured token density
851
+ // instead of assuming 4 chars/token: a CJK-heavy conversation runs near
852
+ // 1 token/char, and a fixed 4-chars/token cut would overshoot the budget
853
+ // by up to ~4x — re-overflowing the very request this bound protects.
854
+ // Verify the complete assembled candidate (elision marker included)
855
+ // against the estimator and shrink until it fits.
856
+ const charsPerToken = conversationText.length / totalEstimatedTokens;
857
+ let budgetChars = Math.floor(inputBudgetTokens * charsPerToken);
858
+ for (let attempt = 0; attempt < 12 && budgetChars > 0; attempt++) {
859
+ const headChars = Math.floor(budgetChars * 0.35);
860
+ const tailChars = Math.max(0, budgetChars - headChars);
861
+ const head = conversationText.slice(0, headChars);
862
+ const tail = tailChars > 0 ? conversationText.slice(conversationText.length - tailChars) : "";
863
+ const assembled = assemble(head, tail);
864
+ if (fitsBudget(assembled)) return assembled;
865
+ budgetChars = Math.floor(budgetChars * 0.8);
866
+ }
867
+ // All attempts overshot (adversarially non-uniform density, or a budget
868
+ // smaller than the marker itself). Fail closed: return the bare marker
869
+ // only when it fits the budget, else an empty excerpt — never an
870
+ // over-budget result.
871
+ return fitsBudget(bareMarker) ? bareMarker : "";
792
872
  }
793
873
 
794
874
  export async function generateSummary(
@@ -1,56 +0,0 @@
1
- /**
2
- * Agent loop that works with AgentMessage throughout.
3
- * Transforms to Message[] only at the LLM call boundary.
4
- */
5
- import { type Context, EventStream } from "@sayknow-cli/ai";
6
- import { type AgentRunCoverage, type AgentRunSummary } from "./run-collector";
7
- import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, StreamFn } from "./types";
8
- /**
9
- * Start an agent loop with a new prompt message.
10
- * The prompt is added to the context and events are emitted for it.
11
- */
12
- export declare function agentLoop(prompts: AgentMessage[], context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: StreamFn): EventStream<AgentEvent, AgentMessage[]>;
13
- /**
14
- * Continue an agent loop from the current context without adding a new message.
15
- * Used for retries - context already has user message or tool results.
16
- *
17
- * **Important:** The last message in context must convert to a `user` or `toolResult` message
18
- * via `convertToLlm`. If it doesn't, the LLM provider will reject the request.
19
- * This cannot be validated here since `convertToLlm` is only called once per turn.
20
- */
21
- export declare function agentLoopContinue(context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: StreamFn): EventStream<AgentEvent, AgentMessage[]>;
22
- /**
23
- * Detailed-result handle returned by {@link agentLoopDetailed}. Adds the
24
- * run-level telemetry/coverage rollup to the existing `AgentMessage[]`
25
- * payload without changing the resolved type of `stream.result()`.
26
- */
27
- export interface AgentLoopDetailedResult {
28
- readonly messages: AgentMessage[];
29
- readonly telemetry: AgentRunSummary | undefined;
30
- readonly coverage: AgentRunCoverage | undefined;
31
- }
32
- /**
33
- * Convenience wrapper over {@link agentLoop} that exposes the run-level
34
- * summary + coverage alongside the messages. The returned `stream` is the
35
- * same `EventStream` callers already consume; `detailed()` awaits the
36
- * stream's `agent_end` event and returns the additive fields.
37
- *
38
- * Existing `stream.result()` semantics are preserved — it still resolves to
39
- * `AgentMessage[]`. Use {@link agentLoopDetailed} when you need the rollup;
40
- * use {@link agentLoop} when you do not.
41
- */
42
- export declare function agentLoopDetailed(prompts: AgentMessage[], context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: StreamFn): {
43
- readonly stream: EventStream<AgentEvent, AgentMessage[]>;
44
- readonly detailed: () => Promise<AgentLoopDetailedResult>;
45
- };
46
- /**
47
- * Like {@link agentLoopDetailed} but built on top of
48
- * {@link agentLoopContinue}.
49
- */
50
- export declare function agentLoopContinueDetailed(context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: StreamFn): {
51
- readonly stream: EventStream<AgentEvent, AgentMessage[]>;
52
- readonly detailed: () => Promise<AgentLoopDetailedResult>;
53
- };
54
- export declare function normalizeMessagesForProvider(messages: Context["messages"], model: AgentLoopConfig["model"]): Context["messages"];
55
- export declare const INTENT_FIELD = "_i";
56
- export declare function normalizeTools(tools: AgentContext["tools"], injectIntent: boolean): Context["tools"];