diff --git a/extensions/realtime-voice/src/bridge/coding-bridge.ts b/extensions/realtime-voice/src/bridge/coding-bridge.ts index e8afe6d..0d0b309 100644 --- a/extensions/realtime-voice/src/bridge/coding-bridge.ts +++ b/extensions/realtime-voice/src/bridge/coding-bridge.ts @@ -129,8 +129,9 @@ export class CodingBridge { // throw on abort is not reliable (the subprocess can linger), and a // run() that never settles would leave currentTask stuck forever → // cancel appears to do nothing and no new task can be started. - const aborted = new Promise((resolve) => { - const onAbort = () => + const abortPromise = new Promise((resolve) => { + const listener = () => { + ac.signal.removeEventListener("abort", listener); resolve({ status: "interrupted", summary: "任务已取消", @@ -139,11 +140,12 @@ export class CodingBridge { unknown >, }); - if (ac.signal.aborted) onAbort(); - else ac.signal.addEventListener("abort", onAbort, { once: true }); + }; + if (ac.signal.aborted) listener(); + else ac.signal.addEventListener("abort", listener, { once: true }); }); const work = this.runAgent(task, shouldResume, ac, progress, emit); - return Promise.race([work, aborted]); + return Promise.race([work, abortPromise]); }, }); } diff --git a/packages/core/src/agent/agent-loop.ts b/packages/core/src/agent/agent-loop.ts index 6c62d3b..c796c86 100644 --- a/packages/core/src/agent/agent-loop.ts +++ b/packages/core/src/agent/agent-loop.ts @@ -31,6 +31,7 @@ import { } from "@step-cli/utils/assistant-message.js"; import { toErrorMessage } from "@step-cli/utils/error.js"; import { clamp } from "@step-cli/utils/math.js"; +import { normalizeToolArguments, stableStringify } from "@step-cli/utils/json.js"; import { truncateText } from "@step-cli/utils/text.js"; import { estimateCompletionRequestPromptTokens } from "@step-cli/utils/token-estimator.js"; import { @@ -1266,38 +1267,6 @@ function createToolCallFingerprint(toolName: string, rawArgs: string): string { return `${toolName}:${hash}`; } -function normalizeToolArguments(rawArgs: string): string { - try { - const parsed = JSON.parse(rawArgs) as unknown; - return stableStringify(parsed); - } catch { - return rawArgs.replace(/\s+/g, " ").trim(); - } -} - -function stableStringify(value: unknown): string { - return JSON.stringify(sortRecursively(value)); -} - -function sortRecursively(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map((entry) => sortRecursively(entry)); - } - - if (value && typeof value === "object") { - const entries = Object.entries(value as Record).sort( - ([left], [right]) => left.localeCompare(right), - ); - const sorted: Record = {}; - for (const [key, child] of entries) { - sorted[key] = sortRecursively(child); - } - return sorted; - } - - return value; -} - function summarizeToolData(value: unknown, maxChars: number): unknown { if (value === undefined) { return undefined; diff --git a/packages/core/src/agent/agent-team.ts b/packages/core/src/agent/agent-team.ts index b0e2701..52de248 100644 --- a/packages/core/src/agent/agent-team.ts +++ b/packages/core/src/agent/agent-team.ts @@ -1374,8 +1374,17 @@ function encodeSessionStorageKey(sessionId: string): string { return Buffer.from(sessionId, "utf8").toString("base64url"); } -async function sleep(delayMs: number): Promise { - await new Promise((resolve) => setTimeout(resolve, delayMs)); +async function sleep(delayMs: number, signal?: AbortSignal): Promise { + if (signal?.aborted) return; + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, delayMs); + if (signal) { + signal.addEventListener("abort", () => { + clearTimeout(timer); + reject(new DOMException("The operation was aborted", "AbortError")); + }, { once: true }); + } + }); } function finalizeTeammateHarness(teammate: LiveTeammate): void { diff --git a/packages/core/src/agent/state-machine.ts b/packages/core/src/agent/state-machine.ts index e6f5341..e988387 100644 --- a/packages/core/src/agent/state-machine.ts +++ b/packages/core/src/agent/state-machine.ts @@ -35,9 +35,27 @@ export interface AgentStateSnapshot { priority?: AgentPriority; } +const ALLOWED_TRANSITIONS: Record = { + goal_start: ["prepare_context", "failed"], + prepare_context: ["model_request", "goal_complete", "failed"], + before_model_request_hooks: ["model_request", "failed"], + context_compaction: ["model_request", "failed"], + model_request: ["tool_execution", "final_response", "goal_complete", "failed"], + tool_execution: ["apply_tool_results", "model_request", "failed"], + apply_tool_results: ["model_request", "final_response", "goal_complete", "failed"], + final_response: ["goal_complete", "failed"], + goal_complete: [], + failed: [], +}; + export class AgentStateMachine { private currentState: AgentState = "prepare_context"; private readonly timeline: AgentStateSnapshot[] = []; + private readonly maxTimelineSize: number; + + constructor(maxTimelineSize = 200) { + this.maxTimelineSize = maxTimelineSize; + } transition(input: { state: AgentState; @@ -45,6 +63,15 @@ export class AgentStateMachine { toolCalls: number; note?: string; }): AgentStateSnapshot { + if (input.state !== this.currentState) { + const allowed = ALLOWED_TRANSITIONS[this.currentState]; + if (allowed && !allowed.includes(input.state)) { + throw new Error( + `Invalid state transition: ${this.currentState} -> ${input.state}`, + ); + } + } + this.currentState = input.state; this.currentState = input.state; const context = getHarnessContext(); const snapshot: AgentStateSnapshot = { diff --git a/packages/core/src/tools/native-impls/file-tools.ts b/packages/core/src/tools/native-impls/file-tools.ts index 79640ac..97b53a0 100644 --- a/packages/core/src/tools/native-impls/file-tools.ts +++ b/packages/core/src/tools/native-impls/file-tools.ts @@ -14,6 +14,7 @@ import { optionalBoolean, optionalNumber, requireString, + safeParse, ToolArgError, } from "./parsers.js"; @@ -152,11 +153,6 @@ function parseEditArgs(rawArgs: string): EditArgs { }; } -function safeParse(rawArgs: string): unknown { - if (!rawArgs?.trim()) return {}; - return JSON.parse(rawArgs); -} - async function readFileExecute( args: ReadArgs, ctx: ToolExecutionContext, diff --git a/packages/core/src/tools/native-impls/parsers.ts b/packages/core/src/tools/native-impls/parsers.ts index f44b87d..f760b91 100644 --- a/packages/core/src/tools/native-impls/parsers.ts +++ b/packages/core/src/tools/native-impls/parsers.ts @@ -72,3 +72,8 @@ export function optionalBoolean( } return value; } + +export function safeParse(rawArgs: string): unknown { + if (!rawArgs?.trim()) return {}; + return JSON.parse(rawArgs); +} diff --git a/packages/core/src/tools/native-impls/shell-tools.ts b/packages/core/src/tools/native-impls/shell-tools.ts index 534bea8..26f23f5 100644 --- a/packages/core/src/tools/native-impls/shell-tools.ts +++ b/packages/core/src/tools/native-impls/shell-tools.ts @@ -14,6 +14,7 @@ import { optionalNumber, optionalString, requireString, + safeParse, } from "./parsers.js"; const SKIP_DIRECTORY_NAMES = new Set([ @@ -159,11 +160,6 @@ function parseGrepArgs(rawArgs: string): GrepArgs { }; } -function safeParse(rawArgs: string): unknown { - if (!rawArgs?.trim()) return {}; - return JSON.parse(rawArgs); -} - async function bashExecute( args: BashArgs, ctx: ToolExecutionContext, diff --git a/packages/core/src/tools/runtime.ts b/packages/core/src/tools/runtime.ts index 117b189..bf76902 100644 --- a/packages/core/src/tools/runtime.ts +++ b/packages/core/src/tools/runtime.ts @@ -14,6 +14,7 @@ import type { ToolSpec, } from "@step-cli/protocol"; import { cloneJsonSchema } from "@step-cli/utils/json-schema.js"; +import { normalizeToolArguments } from "@step-cli/utils/json.js"; import { scoreFuzzyMatch } from "@step-cli/utils/search.js"; import { buildCodeModeToolBindings, @@ -456,11 +457,11 @@ export class ToolRuntime implements ToolRuntimeApi { } if (!cachedApproval && decision === "allow-always") { - this.approvedFingerprints.add(fingerprint); - if (this.approvedFingerprints.size > MAX_APPROVAL_FINGERPRINTS) { - this.approvedFingerprints.clear(); - this.approvedFingerprints.add(fingerprint); + if (this.approvedFingerprints.size >= MAX_APPROVAL_FINGERPRINTS) { + const oldest = this.approvedFingerprints.values().next().value; + if (oldest) this.approvedFingerprints.delete(oldest); } + this.approvedFingerprints.add(fingerprint); } } @@ -812,42 +813,10 @@ function createApprovalFingerprint( return `${toolName}:${explicitFingerprint}`; } - const normalizedArgs = normalizeArgsForFingerprint(rawArgs); + const normalizedArgs = normalizeToolArguments(rawArgs); return `${toolName}:${normalizedArgs}`; } -function normalizeArgsForFingerprint(rawArgs: string): string { - try { - const parsed = JSON.parse(rawArgs) as unknown; - return stableStringify(parsed); - } catch { - return rawArgs.replace(/\s+/g, " ").trim(); - } -} - -function stableStringify(value: unknown): string { - return JSON.stringify(sortRecursively(value)); -} - -function sortRecursively(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map((entry) => sortRecursively(entry)); - } - - if (value && typeof value === "object") { - const entries = Object.entries(value as Record).sort( - ([left], [right]) => left.localeCompare(right), - ); - const sorted: Record = {}; - for (const [key, child] of entries) { - sorted[key] = sortRecursively(child); - } - return sorted; - } - - return value; -} - function parseApprovedFingerprints(state: unknown): string[] | null { if (!state || typeof state !== "object") { return null; diff --git a/packages/utils/src/json.ts b/packages/utils/src/json.ts index a272763..d4a064e 100644 --- a/packages/utils/src/json.ts +++ b/packages/utils/src/json.ts @@ -20,3 +20,33 @@ export function safeParseJson( return fallback; } } + +function sortRecursively(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((entry) => sortRecursively(entry)); + } + if (value && typeof value === "object") { + const entries = Object.entries(value as Record).sort( + ([left], [right]) => left.localeCompare(right), + ); + const sorted: Record = {}; + for (const [key, child] of entries) { + sorted[key] = sortRecursively(child); + } + return sorted; + } + return value; +} + +export function stableStringify(value: unknown): string { + return JSON.stringify(sortRecursively(value)); +} + +export function normalizeToolArguments(rawArgs: string): string { + try { + const parsed = JSON.parse(rawArgs) as unknown; + return stableStringify(parsed); + } catch { + return rawArgs.replace(/\s+/g, " ").trim(); + } +}