@cjhyy/code-shell-core 0.8.0 → 0.8.2

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.
@@ -279,5 +279,9 @@ export function assembleRunToolDefs(args) {
279
279
  const toolDefs = args.runPlanMode
280
280
  ? profileToolDefs.filter((t) => PLAN_MODE_ALLOWED_TOOLS.has(t.name))
281
281
  : profileToolDefs;
282
+ // ToolSearch shares the worker registry across Sessions, but discovery must
283
+ // reflect this exact run's filtered and rewritten surface. Keep the context
284
+ // snapshot in lockstep with the definitions sent to the model.
285
+ toolCtx.searchableToolDefinitions = toolDefs;
282
286
  return toolDefs;
283
287
  }
@@ -202,6 +202,9 @@ export declare class TurnLoop {
202
202
  * loop forces a stop so a stuck goal can't loop forever.
203
203
  */
204
204
  private stopBlockCount;
205
+ /** Consecutive identical tool-call/result batches, ignoring provider call ids. */
206
+ private repeatedToolBatchFingerprint;
207
+ private repeatedToolBatchCount;
205
208
  /**
206
209
  * Run-scoped goal budget tracker (Goal mode). Hoisted to an instance field
207
210
  * (not a run() local) so extend() can bump its budgets mid-run. Null when no
@@ -4,7 +4,7 @@
4
4
  * Following Claude Code's po_() pattern:
5
5
  * pre_check → model_call → post_check → tool_exec → context_mgmt → hook_notify → next turn
6
6
  */
7
- import { randomUUID } from "node:crypto";
7
+ import { createHash, randomUUID } from "node:crypto";
8
8
  import { buildAgentDirectionMessage } from "../tool-system/builtin/agent-notifications.js";
9
9
  import { newTurnId } from "./turn-state.js";
10
10
  import { formatFriendlyError } from "./friendly-error.js";
@@ -40,6 +40,39 @@ export function toolResultToBlock(result) {
40
40
  block.is_error = true;
41
41
  return block;
42
42
  }
43
+ const REPEATED_TOOL_BATCH_LIMIT = 3;
44
+ function canonicalToolValue(value) {
45
+ if (value === null)
46
+ return "null";
47
+ if (value === undefined)
48
+ return "undefined";
49
+ if (Array.isArray(value))
50
+ return `[${value.map(canonicalToolValue).join(",")}]`;
51
+ if (typeof value === "object") {
52
+ return `{${Object.entries(value)
53
+ .sort(([left], [right]) => left.localeCompare(right))
54
+ .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalToolValue(entry)}`)
55
+ .join(",")}}`;
56
+ }
57
+ return JSON.stringify(value);
58
+ }
59
+ function repeatedToolBatchFingerprint(toolCalls, results) {
60
+ // Hash immediately and never log the canonical source: tool results may
61
+ // contain credentials or large media payloads. Call ids are deliberately
62
+ // omitted because providers generate a fresh id for every identical retry.
63
+ return createHash("sha256")
64
+ .update(canonicalToolValue({
65
+ calls: toolCalls.map((call) => ({ toolName: call.toolName, args: call.args })),
66
+ results: results.map((result) => ({
67
+ toolName: result.toolName,
68
+ isError: result.isError === true || Boolean(result.error),
69
+ error: result.error,
70
+ result: result.result,
71
+ contentBlocks: result.contentBlocks,
72
+ })),
73
+ }))
74
+ .digest("hex");
75
+ }
43
76
  export class TurnLoop {
44
77
  deps;
45
78
  config;
@@ -76,6 +109,9 @@ export class TurnLoop {
76
109
  * loop forces a stop so a stuck goal can't loop forever.
77
110
  */
78
111
  stopBlockCount = 0;
112
+ /** Consecutive identical tool-call/result batches, ignoring provider call ids. */
113
+ repeatedToolBatchFingerprint;
114
+ repeatedToolBatchCount = 0;
79
115
  /**
80
116
  * Run-scoped goal budget tracker (Goal mode). Hoisted to an instance field
81
117
  * (not a run() local) so extend() can bump its budgets mid-run. Null when no
@@ -1409,6 +1445,49 @@ export class TurnLoop {
1409
1445
  tlog.info("guard.stale_task", { cat: "guard", turn: this.turnCount });
1410
1446
  }
1411
1447
  }
1448
+ const toolBatchFingerprint = repeatedToolBatchFingerprint(toolCalls, results);
1449
+ if (toolBatchFingerprint === this.repeatedToolBatchFingerprint) {
1450
+ this.repeatedToolBatchCount++;
1451
+ }
1452
+ else {
1453
+ this.repeatedToolBatchFingerprint = toolBatchFingerprint;
1454
+ this.repeatedToolBatchCount = 1;
1455
+ }
1456
+ if (this.repeatedToolBatchCount >= REPEATED_TOOL_BATCH_LIMIT) {
1457
+ tlog.warn("turn.repeated_tool_batch_stopped", {
1458
+ cat: "turn",
1459
+ repeatedCount: this.repeatedToolBatchCount,
1460
+ tools: toolCalls.map((call) => call.toolName),
1461
+ });
1462
+ await this.emitHook("on_turn_end", {
1463
+ turnNumber: this.turnCount,
1464
+ hasToolUse: true,
1465
+ toolCallCount: toolCalls.length,
1466
+ });
1467
+ finalText =
1468
+ `检测到同一组工具调用及其结果连续重复 ${REPEATED_TOOL_BATCH_LIMIT} 次,` +
1469
+ "已自动停止,避免继续空转。请调整请求或让 Session 获取新的上下文后再试。";
1470
+ this.deps.transcript.appendMessage("assistant", finalText);
1471
+ messages.push({ role: "assistant", content: finalText });
1472
+ this.config.onStream?.({
1473
+ type: "assistant_message",
1474
+ messageId: assistantMessageId,
1475
+ message: { role: "assistant", content: finalText },
1476
+ });
1477
+ this.finalizeModelTurn();
1478
+ if (await this.consumeQueuedSteer(messages, "finalize_backfill")) {
1479
+ this.repeatedToolBatchFingerprint = undefined;
1480
+ this.repeatedToolBatchCount = 0;
1481
+ continue;
1482
+ }
1483
+ messages = this.redactConsumedSensitiveToolResults(messages);
1484
+ return {
1485
+ text: finalText,
1486
+ reason: "completed",
1487
+ messages,
1488
+ completionKind: "limit_stop",
1489
+ };
1490
+ }
1412
1491
  // Hook: turn end
1413
1492
  await this.emitHook("on_turn_end", {
1414
1493
  turnNumber: this.turnCount,
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Public API exports.
5
5
  */
6
- export declare const VERSION = "0.8.0";
6
+ export declare const VERSION = "0.8.2";
7
7
  export type { Message, ContentBlock, ToolDefinition, ToolCall, ToolResult, RegisteredTool, TranscriptEvent, TranscriptEventType, SessionState, SessionKind, SessionWorkspace, SessionForkLineage, ContextUsageAnchor, SessionStatus, TokenUsage, CompiledInput, PermissionDecision, PermissionMode, PermissionRule, TurnPhase, TurnResult, TerminalReason, TurnCompletionKind, StreamEvent, StreamCallback, LLMConfig, ClientDefaults, LLMResponse, Settings, MCPServerConfig, } from "./types.js";
8
8
  export type { GoalConfig, GoalLifecycleConfig, GoalLifecyclePhase, GoalLifecycleTerminalReason, GoalLifecycleV1, } from "./goal/lifecycle.js";
9
9
  export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, SandboxUnavailableError, } from "./exceptions.js";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Public API exports.
5
5
  */
6
- export const VERSION = "0.8.0";
6
+ export const VERSION = "0.8.2";
7
7
  // ─── Exceptions ──────────────────────────────────────────────────
8
8
  export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, SandboxUnavailableError, } from "./exceptions.js";
9
9
  // ─── Engine (primary API) ────────────────────────────────────────
@@ -8,9 +8,10 @@
8
8
  import { isRegisteredMcpToolAllowed } from "../mcp-tool-policy.js";
9
9
  export const toolSearchToolDef = {
10
10
  name: "ToolSearch",
11
- description: "Search for and discover available tools by name or keyword. " +
11
+ description: "Search only the tools available in the current Session context by name or keyword. " +
12
12
  "Some tools (especially from MCP servers) are deferred — their full schemas " +
13
- "are only loaded when you search for them. Use this to find the right tool.",
13
+ "are only loaded when you search for them. If an exact tool is reported unavailable, " +
14
+ "do not retry unless the Session context changes.",
14
15
  inputSchema: {
15
16
  type: "object",
16
17
  properties: {
@@ -37,6 +38,18 @@ export async function toolSearchTool(args, ctx) {
37
38
  // returning the wrong tool set. `|| 5` only caught 0/NaN, not negatives.
38
39
  const rawMax = args.max_results;
39
40
  const maxResults = Math.min(typeof rawMax === "number" && rawMax > 0 ? rawMax : 5, 20);
41
+ const currentDefinitions = ctx.searchableToolDefinitions;
42
+ if (currentDefinitions) {
43
+ const currentTools = currentDefinitions.map((definition) => definitionToSearchableTool(definition, ctx.toolRegistry));
44
+ if (query.startsWith("select:")) {
45
+ const names = query
46
+ .slice(7)
47
+ .split(",")
48
+ .map((name) => name.trim());
49
+ return matchCurrentExact(currentTools, names);
50
+ }
51
+ return searchCurrentByKeyword(currentTools, query, maxResults);
52
+ }
40
53
  // The tool registry is worker-SHARED (B1): it holds MCP tools registered by
41
54
  // every session, including servers another project enabled. ToolSearch is a
42
55
  // side door around the per-turn toolDefs filter — without this gate it would
@@ -63,6 +76,32 @@ export async function toolSearchTool(args, ctx) {
63
76
  // Keyword search
64
77
  return searchByKeyword(ctx.toolRegistry, query, maxResults, visible);
65
78
  }
79
+ function definitionToSearchableTool(definition, registry) {
80
+ const registered = registry.getTool(definition.name);
81
+ return {
82
+ name: definition.name,
83
+ description: definition.description,
84
+ inputSchema: definition.inputSchema,
85
+ source: registered?.source ?? "builtin",
86
+ ...(registered?.serverName ? { serverName: registered.serverName } : {}),
87
+ ...(registered?.mcpToolName ? { mcpToolName: registered.mcpToolName } : {}),
88
+ permissionDefault: registered?.permissionDefault ?? "ask",
89
+ };
90
+ }
91
+ function matchCurrentExact(tools, names) {
92
+ const byName = new Map(tools.map((tool) => [tool.name, tool]));
93
+ return names
94
+ .map((name) => {
95
+ const tool = byName.get(name);
96
+ return tool
97
+ ? formatTool(tool)
98
+ : `Tool "${name}" is not available in the current Session context. Do not retry unless the Session context changes.`;
99
+ })
100
+ .join("\n\n---\n\n");
101
+ }
102
+ function searchCurrentByKeyword(tools, query, maxResults) {
103
+ return searchDetailedTools(tools, query, maxResults);
104
+ }
66
105
  function matchExact(registry, names, visible) {
67
106
  const results = [];
68
107
  for (const name of names) {
@@ -78,6 +117,9 @@ function matchExact(registry, names, visible) {
78
117
  }
79
118
  function searchByKeyword(registry, query, maxResults, visible) {
80
119
  const allTools = registry.listToolsDetailed().filter(visible);
120
+ return searchDetailedTools(allTools, query, maxResults);
121
+ }
122
+ function searchDetailedTools(allTools, query, maxResults) {
81
123
  const queryLower = query.toLowerCase();
82
124
  const keywords = queryLower.split(/\s+/);
83
125
  // Score each tool
@@ -12,7 +12,7 @@
12
12
  * Tools that don't need any context (Read/Write/Bash/...) just ignore
13
13
  * the second argument; the type is purely additive.
14
14
  */
15
- import type { LLMConfig, StreamCallback, TokenUsage } from "../types.js";
15
+ import type { LLMConfig, StreamCallback, TokenUsage, ToolDefinition } from "../types.js";
16
16
  import type { ModelPool } from "../llm/model-pool.js";
17
17
  import type { ToolRegistry } from "./registry.js";
18
18
  import type { AgentPresetName } from "../preset/index.js";
@@ -242,6 +242,13 @@ export interface ToolContext {
242
242
  modelPool?: ModelPool;
243
243
  /** Tool registry (ToolSearch reads this to enumerate available tools). */
244
244
  toolRegistry: ToolRegistry;
245
+ /**
246
+ * Final per-turn tool surface after availability guards, feature flags,
247
+ * behavior-profile allowlists, plan-mode filtering, and definition rewrites.
248
+ * ToolSearch must prefer this list over the worker-shared registry so it
249
+ * cannot advertise a tool the current Session cannot actually call.
250
+ */
251
+ searchableToolDefinitions?: readonly ToolDefinition[];
245
252
  /** Opaque services contributed by capability modules, keyed by capability id. */
246
253
  capabilityServices?: Readonly<Record<string, unknown>>;
247
254
  /**
@@ -29,11 +29,12 @@
29
29
  * approval round-trip; it is not an authority to write anywhere on disk.
30
30
  * Callers must consult classifyPath before honoring acceptEdits.
31
31
  */
32
- import { constants, realpathSync, existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, } from "node:fs";
32
+ import { constants, realpathSync, existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, statSync, } from "node:fs";
33
33
  import { open } from "node:fs/promises";
34
34
  import { homedir } from "node:os";
35
35
  import { randomUUID } from "node:crypto";
36
- import { dirname, isAbsolute, join, resolve as resolvePath, sep } from "node:path";
36
+ import { dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
37
+ import { readInstalledPlugins } from "../plugins/installedPlugins.js";
37
38
  const FINAL_WRITE_PATH_SNAPSHOT = Symbol("codeshell.finalWritePathSnapshot");
38
39
  /**
39
40
  * Default sensitive path patterns. These are evaluated AFTER home-expansion
@@ -392,6 +393,88 @@ function isInsideDir(child, parent) {
392
393
  const p = par.endsWith(sep) ? par : par + sep;
393
394
  return c === par || c.startsWith(p);
394
395
  }
396
+ function configuredUserHome() {
397
+ return process.env.HOME ?? homedir();
398
+ }
399
+ /**
400
+ * Whether `resolved` belongs to one concrete Skill tree rooted below
401
+ * `<skillsRoot>/<skill>/`.
402
+ *
403
+ * Both the root and manifest are realpathed, so a reference symlink cannot
404
+ * turn this read-only exception into access outside the managed Skill tree.
405
+ */
406
+ function isSkillTreeResource(resolved, skillsRoot) {
407
+ try {
408
+ const realSkillsRoot = realpathSync(skillsRoot);
409
+ if (!isInsideDir(resolved, realSkillsRoot))
410
+ return false;
411
+ const rel = relative(realSkillsRoot, resolved);
412
+ const skillName = rel.split(sep).filter(Boolean)[0];
413
+ if (!skillName || skillName === "..")
414
+ return false;
415
+ const skillRoot = realpathSync(join(realSkillsRoot, skillName));
416
+ if (!isInsideDir(skillRoot, realSkillsRoot))
417
+ return false;
418
+ const manifest = realpathSync(join(skillRoot, "SKILL.md"));
419
+ if (!isInsideDir(manifest, skillRoot) || !statSync(manifest).isFile())
420
+ return false;
421
+ return isInsideDir(resolved, skillRoot);
422
+ }
423
+ catch {
424
+ return false;
425
+ }
426
+ }
427
+ /**
428
+ * Skill instructions routinely link to sibling references/scripts/assets.
429
+ * The Skill builtin can already read the registered SKILL.md, so asking again
430
+ * for every referenced file is both inconsistent and capable of wedging a
431
+ * headless run.
432
+ *
433
+ * Keep the exception narrow:
434
+ * - read-only (the caller checks the operation);
435
+ * - user Skills, or an installed plugin recorded in the V2 registry;
436
+ * - plugin installs must realpath beneath the managed plugin cache;
437
+ * - the target must remain inside a directory containing a real SKILL.md.
438
+ */
439
+ function isRegisteredSkillResourceRead(resolved) {
440
+ let codeShellRoot;
441
+ try {
442
+ codeShellRoot = realpathSync(join(configuredUserHome(), ".code-shell"));
443
+ }
444
+ catch {
445
+ return false;
446
+ }
447
+ if (!isInsideDir(resolved, codeShellRoot))
448
+ return false;
449
+ if (isSkillTreeResource(resolved, join(codeShellRoot, "skills")))
450
+ return true;
451
+ let cacheRoot;
452
+ try {
453
+ cacheRoot = realpathSync(join(codeShellRoot, "plugins", "cache"));
454
+ }
455
+ catch {
456
+ return false;
457
+ }
458
+ const installed = readInstalledPlugins();
459
+ for (const entries of Object.values(installed.plugins)) {
460
+ for (const entry of entries) {
461
+ try {
462
+ const installRoot = realpathSync(entry.installPath);
463
+ if (installRoot === cacheRoot || !isInsideDir(installRoot, cacheRoot))
464
+ continue;
465
+ const skillsRoot = realpathSync(join(installRoot, "skills"));
466
+ if (!isInsideDir(skillsRoot, installRoot))
467
+ continue;
468
+ if (isSkillTreeResource(resolved, skillsRoot))
469
+ return true;
470
+ }
471
+ catch {
472
+ // A stale/tampered registry entry must never broaden read authority.
473
+ }
474
+ }
475
+ }
476
+ return false;
477
+ }
395
478
  /**
396
479
  * Returns the matching sensitive-dir entry (with the user's home prefix) if
397
480
  * `resolved` lives underneath any sensitive directory, else undefined.
@@ -476,6 +559,20 @@ export function classifyPath(rawPath, opts) {
476
559
  const sensitiveFile = matchSensitiveFile(resolved);
477
560
  const sensitiveLabel = sensitiveDir ?? sensitiveFile;
478
561
  const insideWorkspace = isInsideDir(resolved, workspace);
562
+ // Registered Skill resources are managed runtime inputs. Their SKILL.md is
563
+ // already readable through the Skill builtin; allow its contained reference
564
+ // files through the ordinary Read tool as well. A credential-shaped basename
565
+ // (.env, token.txt, key files, ...) deliberately keeps the sensitive-file
566
+ // gate even inside a Skill tree.
567
+ if (opts.operation === "read" &&
568
+ !sensitiveFile &&
569
+ isRegisteredSkillResourceRead(resolved)) {
570
+ return {
571
+ decision: "allow",
572
+ reason: "registered Skill resource read",
573
+ resolvedPath: resolved,
574
+ };
575
+ }
479
576
  // Sensitive: write is always denied, read always asks. Workspace placement
480
577
  // doesn't soften the rule — an `.env` in the project still asks on read.
481
578
  if (sensitiveLabel) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-core",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Core engine for code-shell — agent orchestration, tool execution, hooks, protocol. UI-agnostic.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",