Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"dist",
"licenses",
"scripts/prepare.mjs",
"scripts/run-step.mjs",
"README.md",
"README_CN.md"
],
Expand All @@ -37,6 +38,8 @@
"test": "vitest run",
"test:watch": "vitest",
"start": "node dist/main.js",
"step": "node scripts/run-step.mjs",
"step:fresh": "node scripts/run-step.mjs --full",
Comment on lines 40 to +42
"prepare": "node scripts/prepare.mjs",
"prepublishOnly": "npm run build",
"prepack": "npm run build"
Expand Down
105 changes: 105 additions & 0 deletions scripts/run-step.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import path from "node:path";
import { spawn } from "node:child_process";

const repoRoot = process.cwd();
const scriptArgs = process.argv.slice(2);

async function resolveBunBin() {
const explicit = process.env.STEP_BUN_BIN;
if (explicit) {
return explicit;
}

const candidates = [
"bun",
path.join(process.env.USERPROFILE ?? "", ".bun", "bin", "bun.exe"),
path.join(process.env.LOCALAPPDATA ?? "", "Programs", "bun", "bun.exe"),
];

for (const candidate of candidates) {
const ok = await checkCommand(candidate);
if (ok) {
return candidate;
}
}

return process.execPath;
}

async function checkCommand(command) {
return new Promise((resolve) => {
let child;
try {
child = spawn(command, ["--version"], {
cwd: repoRoot,
env: process.env,
stdio: ["ignore", "pipe", "ignore"],
});
} catch {
resolve(false);
return;
}

let stdout = "";
child.stdout.on("data", (chunk) => {
stdout += chunk;
});

child.once("error", () => {
resolve(false);
});

child.once("close", (code) => {
resolve(code === 0 && stdout.trim().length > 0);
});
});
}

async function main() {
const entrypoint = path.join(repoRoot, "src", "main.ts");
const bunBin = await resolveBunBin();
const isNode = bunBin === process.execPath || /(^|[/\\])node(\.exe)?$/.test(bunBin);

if (!isNode) {
return runCommand(bunBin, [entrypoint, ...scriptArgs]);
}

return runCommand(process.execPath, [
"--import",
pathToFileURL(require.resolve("tsx")).href,
entrypoint,
...scriptArgs,
]);
Comment on lines +67 to +72
}

function runCommand(command, commandArgs) {
return new Promise((resolve, reject) => {
const child = spawn(command, commandArgs, {
cwd: repoRoot,
env: process.env,
stdio: "inherit",
});

child.once("error", reject);
child.once("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}

resolve(code ?? 1);
});
});
}

function pathToFileURL(path) {
return `file://${path.replace(/\\/g, "/")}`;
}

main().then(
(exitCode) => process.exit(exitCode),
(error) => {
console.error(`step-cli wrapper error: ${error.message}`);
process.exit(1);
}
);
2 changes: 1 addition & 1 deletion src/agent/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export type AgentEvent =
| { type: 'tool_forming'; id: string; name: string }
/** 工具参数的流式增量(半截 JSON 片段)。UI 只抠关键字段做预览,不解析全量。 */
| { type: 'tool_args_delta'; id: string; partialJson: string }
| { type: 'tool_end'; id: string; name: string; result: string; isError: boolean }
| { type: 'tool_end'; id: string; name: string; result: string; isError: boolean; errorCode?: string }
/**
* 重试。hadPartial 为 true 表示本次失败尝试已吐过正文(屏幕上有残文条目),
* UI 据此在重试前移除该残文(B 方案:撤回气泡,只留重发的完整版);
Expand Down
2 changes: 1 addition & 1 deletion src/agent/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export interface ToolCallRequest {
}

/** 授权结果:放行,或拒绝并附原因(原因会作为 tool_result 回灌给模型)。 */
export type Authorization = { decision: 'allow' } | { decision: 'deny'; reason: string };
export type Authorization = { decision: 'allow' } | { decision: 'deny'; reason: string; errorCode?: string };

/** 停止后续接描述:inject 为下一轮要注入的用户消息文本。 */
export interface StopContinuation {
Expand Down
6 changes: 4 additions & 2 deletions src/agent/runTurn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,9 @@ export async function* runTurn(
// 授权(Phase 3 权限系统挂在这里)
const auth = await resolveAuthorization(hooks, req);
if (auth.decision === 'deny') {
prepared.push({ tu, access: { kind: 'all' }, preset: { content: `工具调用被拒绝:${auth.reason}`, isError: true } });
const preset: ToolResult = { content: `工具调用被拒绝:${auth.reason}`, isError: true };
if (auth.errorCode !== undefined) preset.errorCode = auth.errorCode;
prepared.push({ tu, access: { kind: 'all' }, preset });
continue;
}
prepared.push({
Expand Down Expand Up @@ -644,7 +646,7 @@ export async function* runTurn(
continue;
}
const result = p.result!;
yield { type: 'tool_end', id: p.tu.id, name: p.tu.name, result: result.content, isError: result.isError };
yield { type: 'tool_end', id: p.tu.id, name: p.tu.name, result: result.content, isError: result.isError, errorCode: result.errorCode };
toolResults.push(makeToolResult(p.tu.id, result));
// 完成放行:被本任务卡住的后续任务现在启动,其 tool_start 排在本 tool_end 之后
scheduler.drain();
Expand Down
2 changes: 2 additions & 0 deletions src/chat/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export type DisplayItem =
/** 子 agent 终态统计:工具调用次数与墙钟耗时(end 事件带回)。 */
subagentToolUses?: number;
subagentDurationMs?: number;
/** 结构化错误码(可选)。TUI 可按 code 做特殊渲染,如 PLAN_MODE_BLOCKED。 */
errorCode?: string;
}
| {
kind: 'note';
Expand Down
2 changes: 2 additions & 0 deletions src/tools/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ export interface ToolResult {
* anthropic → 同形状 video 扩展块),能力不支持时由投影层换占位文本。
*/
videos?: ToolResultVideo[];
/** 结构化错误码(可选)。TUI / 协议层可按 code 区分错误类型,不再只靠 content 文本推断。 */
errorCode?: string;
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/tui-pi/PiChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3038,7 +3038,7 @@ ${task.output === '' ? '(暂无输出)' : task.output}`,
// plan 模式守卫:写与执行一律拒(exit_plan_mode 例外,走下方确认)
if (this.planMode) {
const deny = planModeDenyReason(req.name);
if (deny !== null) return { decision: 'deny', reason: deny };
if (deny !== null) return { decision: 'deny', reason: deny, errorCode: 'PLAN_MODE_BLOCKED' };
}
// exit_plan_mode:展示计划请用户确认,批准后退出 plan 并恢复原权限模式
if (req.name === 'exit_plan_mode') {
Expand Down Expand Up @@ -3459,7 +3459,7 @@ ${task.output === '' ? '(暂无输出)' : task.output}`,
this.activity.noteToolActivity();
this.transcript.updateLastWhere(
(it) => it.kind === 'tool' && it.id === ev.id,
(it) => ({ ...(it as Extract<DisplayItem, { kind: 'tool' }>), status: ev.isError ? 'error' : 'ok', result: ev.result }),
(it) => ({ ...(it as Extract<DisplayItem, { kind: 'tool' }>), status: ev.isError ? 'error' : 'ok', result: ev.result, errorCode: ev.errorCode }),
);
// todo_list 工具改的是 this.todos,面板要跟着刷;其它工具走这一路开销是两次赋值
this.chrome.setTodos(this.todos.items);
Expand Down
6 changes: 6 additions & 0 deletions tests/agent/permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,10 @@ describe('planModeDenyReason', () => {
expect(planModeDenyReason('get_goal')).toBeNull();
expect(planModeDenyReason('cron_list')).toBeNull();
});

it('plan mode deny reason 文案包含 exit_plan_mode 引导', () => {
const reason = planModeDenyReason('write_file');
expect(reason).toContain('exit_plan_mode');
expect(reason).toContain('write_file');
});
});
28 changes: 28 additions & 0 deletions tests/agent/runTurnParallel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,34 @@ describe('runTurn 并行工具执行', () => {
expect(events.at(-1)!.type).toBe('turn_done');
});

it('授权拒绝可带 errorCode:plan mode 下返回 PLAN_MODE_BLOCKED', async () => {
const hooks: LoopHooks = {
authorizeToolCall: (req) =>
req.name === 'write_file'
? { decision: 'deny', reason: 'plan mode blocked', errorCode: 'PLAN_MODE_BLOCKED' }
: { decision: 'allow' },
};
const { provider } = makeFakeProvider([
{
textChunks: [],
finalContent: [
toolUseBlock('c1', 'write_file', { path: 'should-not-exist.txt', content: 'x' }),
],
},
{ textChunks: ['好的'], finalContent: [textBlock('好的')] },
]);
const messages: StoredMessage[] = [sm('go')];
const events = await collect(runAgent(base(provider, messages, { hooks })));

const blocks = toolResultBlocks(messages);
expect(blocks[0]!.is_error).toBe(true);
expect(String(blocks[0]!.content)).toContain('plan mode blocked');
const ends = events.filter((e) => e.type === 'tool_end') as { id: string; isError: boolean; errorCode?: string }[];
const end = ends.find((e) => e.id === 'c1');
expect(end?.isError).toBe(true);
expect(end?.errorCode).toBe('PLAN_MODE_BLOCKED');
});

it('异常隔离:单个工具的结果后处理抛异常 → 该槽转 is_error,兄弟任务照常', async () => {
const hooks: LoopHooks = {
finalizeToolResult: (req, result) => {
Expand Down
Loading