Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions extensions/realtime-voice/src/bridge/coding-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TaskFinalSummary>((resolve) => {
const onAbort = () =>
const abortPromise = new Promise<TaskFinalSummary>((resolve) => {
const listener = () => {
ac.signal.removeEventListener("abort", listener);
resolve({
status: "interrupted",
summary: "任务已取消",
Expand All @@ -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]);
},
});
}
Expand Down
33 changes: 1 addition & 32 deletions packages/core/src/agent/agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown>).sort(
([left], [right]) => left.localeCompare(right),
);
const sorted: Record<string, unknown> = {};
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;
Expand Down
13 changes: 11 additions & 2 deletions packages/core/src/agent/agent-team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1374,8 +1374,17 @@ function encodeSessionStorageKey(sessionId: string): string {
return Buffer.from(sessionId, "utf8").toString("base64url");
}

async function sleep(delayMs: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, delayMs));
async function sleep(delayMs: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return;
await new Promise<void>((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 {
Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/agent/state-machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,43 @@ export interface AgentStateSnapshot {
priority?: AgentPriority;
}

const ALLOWED_TRANSITIONS: Record<AgentState, AgentState[]> = {
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;
step: number;
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 = {
Expand Down
6 changes: 1 addition & 5 deletions packages/core/src/tools/native-impls/file-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
optionalBoolean,
optionalNumber,
requireString,
safeParse,
ToolArgError,
} from "./parsers.js";

Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/tools/native-impls/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,8 @@ export function optionalBoolean(
}
return value;
}

export function safeParse(rawArgs: string): unknown {
if (!rawArgs?.trim()) return {};
return JSON.parse(rawArgs);
}
6 changes: 1 addition & 5 deletions packages/core/src/tools/native-impls/shell-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
optionalNumber,
optionalString,
requireString,
safeParse,
} from "./parsers.js";

const SKIP_DIRECTORY_NAMES = new Set([
Expand Down Expand Up @@ -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,
Expand Down
43 changes: 6 additions & 37 deletions packages/core/src/tools/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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<string, unknown>).sort(
([left], [right]) => left.localeCompare(right),
);
const sorted: Record<string, unknown> = {};
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;
Expand Down
30 changes: 30 additions & 0 deletions packages/utils/src/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,33 @@ export function safeParseJson<T = unknown>(
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<string, unknown>).sort(
([left], [right]) => left.localeCompare(right),
);
const sorted: Record<string, unknown> = {};
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();
}
}
Loading