fix(agent): calls the host stops before they run carry a refusal identity the window words

This commit is contained in:
esengine committed 2026-10-27 02:37:11 -07:00
1 parent d5ead8314c
commit 96cd01d6e2
9 files changed
+143 -9

No files matched your search

@@ -0,0 +1,32 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { boot, STORAGE } from "./index";
import { say } from "./kernel";
import { toolErrorDetail, toolRefusalReason } from "../ui/cards/outcome";
import type { Tool } from "../port/wire";
afterEach(() => { localStorage.setItem(STORAGE, "zh"); boot(); });
// The calls the host stops before they run. The model keeps the English text;
// the person reading the card gets the reason in their own language.
const BLOCKED = [
"shell.mixed_mutation_verification",
"shell.inline_interpreter_not_last",
"batch.skipped_after_failed_change",
"batch.check_after_stopped_call",
];
describe("calls the host stops before they run", () => {
for (const language of ["zh", "en"]) {
it(`are worded in ${language}, and the card shows that instead of the English line`, () => {
localStorage.setItem(STORAGE, language);
boot();
for (const code of BLOCKED) {
const tool = { name: "bash", args: {}, err: "blocked: raw English wording", refusalCode: code } as unknown as Tool;
expect(say({ code }, ""), code).not.toBe("");
expect(toolRefusalReason(tool), code).not.toBe("");
expect(toolErrorDetail(tool), `${code}: the English line is not drawn when the reason is`).toBe("");
}
});
}
});
@@ -13,6 +13,10 @@ export const EN_KERNEL: Record<string, string> = {
"该路径指向网络上的另一台机器,不在当前工作区内。文件工具只按路径写法拒绝它,不会去访问;工作区本身在网络共享上时,其下的路径可用": "That path names another machine on the network and is outside this workspace. File tools refuse it by its spelling and never look it up; paths below a workspace that is itself on a network share are allowed",
"内置浏览器不会打开指向网络上另一台机器的路径,只按路径写法拒绝,不会去访问": "The built-in browser does not open a path that names another machine on the network; it is refused by its spelling and never looked up",
"无法读取目标文件:它在你配置的禁读列表里。": "Cannot read the target file: it is on the forbidden-read list you configured.",
"这条命令把一个宿主无法确认不改动工作区的步骤和验证检查写在了一起,已拦截,没有运行。请把它们拆成两次调用:先改动,再单独验证。": "This command puts a step the host cannot confirm leaves the workspace alone together with a verification check, so it was blocked and did not run. Split it into two calls: change first, then verify on its own.",
"这条命令里的内联解释器不是最后一段,后面的步骤会决定整条命令的退出状态,解释器里的失败会被当成成功,已拦截,没有运行。请把它放在最后,或单独运行。": "The inline interpreter in this command is not its last segment, so later steps decide the exit status and a failure inside the interpreter would read as success. It was blocked and did not run. Put it last, or run it on its own.",
"同一批调用里前面的修改失败或被拦截了,所以这一步没有执行。请先处理那处修改,再重试。": "An earlier change in the same batch of calls failed or was blocked, so this step did not run. Deal with that change first, then retry.",
"同一批调用里前面有一次调用在运行前就被停止了,这次检查测不出你要的状态,所以没有执行。请先重跑被停止的调用,或单独运行这次检查。": "An earlier call in the same batch was stopped before it ran, so this check would not measure the state you asked for and did not run. Re-run the stopped call first, or run this check on its own.",
"无法读取目标文件:它不在这个会话允许读取的文件夹里。": "Cannot read the target file: it is outside the folders this session may read.",
"读视频需要本机装有 ffmpeg(含 ffprobe)并能在系统路径里找到。装好后再试一次。": "Reading a video needs ffmpeg (with ffprobe) installed and on the system path. Install it and try again.",
"不认识这种视频格式。支持 MP4/MOV、MKV/WebM、AVI、FLV、ASF、MPEG 和 Ogg 这些常见封装。": "This video format is not recognised. MP4/MOV, MKV/WebM, AVI, FLV, ASF, MPEG and Ogg containers are supported.",
+4
View File
@@ -123,6 +123,10 @@ const SAID: Record<string, string> = {
"workspace.root_not_granted": "这个文件夹没有授权给当前工作区",
"workspace.target_access_unconfigured": "未配置文件访问策略,无法执行此文件操作。",
"workspace.read_forbidden": "无法读取目标文件:它在你配置的禁读列表里。",
"shell.mixed_mutation_verification": "这条命令把一个宿主无法确认不改动工作区的步骤和验证检查写在了一起,已拦截,没有运行。请把它们拆成两次调用:先改动,再单独验证。",
"shell.inline_interpreter_not_last": "这条命令里的内联解释器不是最后一段,后面的步骤会决定整条命令的退出状态,解释器里的失败会被当成成功,已拦截,没有运行。请把它放在最后,或单独运行。",
"batch.skipped_after_failed_change": "同一批调用里前面的修改失败或被拦截了,所以这一步没有执行。请先处理那处修改,再重试。",
"batch.check_after_stopped_call": "同一批调用里前面有一次调用在运行前就被停止了,这次检查测不出你要的状态,所以没有执行。请先重跑被停止的调用,或单独运行这次检查。",
"workspace.read_outside_scope": "无法读取目标文件:它不在这个会话允许读取的文件夹里。",
"video.ffmpeg_missing": "读视频需要本机装有 ffmpeg(含 ffprobe)并能在系统路径里找到。装好后再试一次。",
"video.unsupported_container": "不认识这种视频格式。支持 MP4/MOV、MKV/WebM、AVI、FLV、ASF、MPEG 和 Ogg 这些常见封装。",
@@ -40,6 +40,10 @@ export function toolErrorDetail(tool: Tool): string {
case "video.ffmpeg_missing":
case "video.unsupported_container":
case "video.too_large":
case "shell.mixed_mutation_verification":
case "shell.inline_interpreter_not_last":
case "batch.skipped_after_failed_change":
case "batch.check_after_stopped_call":
if (toolRefusalReason(tool)) return "";
}
return tool.err ?? "";
@@ -0,0 +1,63 @@
package agent
import (
"context"
"testing"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
"reasonix/internal/state/sessionstore"
)
func refusalOf(t *testing.T, a *Agent, id string) string {
t.Helper()
for _, msg := range a.sess.conversation.Snapshot() {
if msg.ToolCallID == id && msg.Role == provider.RoleTool {
if msg.ToolFailure == nil {
t.Fatalf("call %s carries no failure", id)
}
return msg.ToolFailure.RefusalCode
}
}
t.Fatalf("no result for call %s", id)
return ""
}
func runBash(t *testing.T, command string) *Agent {
t.Helper()
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "bash", readOnly: false})
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{toolCallChunk("m1", "bash", `{"command":`+quoteJSON(command)+`}`), {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "ok"}, {Type: provider.ChunkDone}},
}}
a := New(prov, reg, sessionstore.NewSession(""), Options{}, event.Discard)
if err := a.Run(context.Background(), "test"); err != nil {
t.Fatal(err)
}
return a
}
func TestAMixedMutationAndVerificationBlockNamesItsIdentity(t *testing.T) {
a := runBash(t, "go generate ./... ; go test ./...")
if got := refusalOf(t, a, "m1"); got != CodeShellMixedMutationVerification {
t.Fatalf("refusal code = %q, want %q", got, CodeShellMixedMutationVerification)
}
}
func TestANonTerminalInlineInterpreterBlockNamesItsIdentity(t *testing.T) {
a := runBash(t, `python3 -c 'open("x","w").write("y")' ; node verify_frontend_logic.js`)
if got := refusalOf(t, a, "m1"); got != CodeShellInlineInterpreterNotLast {
t.Fatalf("refusal code = %q, want %q", got, CodeShellInlineInterpreterNotLast)
}
}
func TestTheBarrierLevelsNameTheirIdentity(t *testing.T) {
if got := barrierFull.refusalCode(); got != CodeBatchSkippedAfterFailedChange {
t.Fatalf("full barrier = %q, want %q", got, CodeBatchSkippedAfterFailedChange)
}
if got := barrierVerificationOnly.refusalCode(); got != CodeBatchCheckAfterStoppedCall {
t.Fatalf("verification-only barrier = %q, want %q", got, CodeBatchCheckAfterStoppedCall)
}
}
+15 -1
View File
@@ -36,6 +36,20 @@ func (l barrierLevel) covers(mutates, verification bool) bool {
return false
}
// Refusal identities of the calls the host stops before they run, so a window
// can say why in the reader's language while the model keeps the English text.
const (
CodeBatchSkippedAfterFailedChange = "batch.skipped_after_failed_change"
CodeBatchCheckAfterStoppedCall = "batch.check_after_stopped_call"
)
func (l barrierLevel) refusalCode() string {
if l == barrierVerificationOnly {
return CodeBatchCheckAfterStoppedCall
}
return CodeBatchSkippedAfterFailedChange
}
// message says which of the two happened: told a modification failed when none
// ran, a model goes looking for a write it never made.
func (l barrierLevel) message() string {
@@ -376,7 +390,7 @@ func (a *Agent) barrierSkip(call provider.ToolCall, level barrierLevel) (toolOut
ex.Verification = tool.ShellVerificationNotRun
}
}
return toolOutcome{output: msg, blocked: true, errMsg: firstLine(msg), execution: ex}, true
return toolOutcome{output: msg, blocked: true, errMsg: firstLine(msg), refusalCode: level.refusalCode(), execution: ex}, true
}
// batchFailureBarrier reports how far a finished call's failure reaches: a
+16 -8
View File
@@ -6,6 +6,12 @@ import (
"reasonix/internal/safety/evidence"
)
// Refusal identities of the shell shapes the host refuses before running.
const (
CodeShellMixedMutationVerification = "shell.mixed_mutation_verification"
CodeShellInlineInterpreterNotLast = "shell.inline_interpreter_not_last"
)
// applyShellShapeGates holds the deterministic command-shape contract: shapes a
// later segment could use to hide an earlier failure, and the shape a declared
// check needs for its exit status to mean anything. Ordinary mode blocks only
@@ -51,19 +57,21 @@ func (a *Agent) applyShellShapeGates(ctx context.Context, plan *toolCallPlan) (t
msg = evidence.ShellContractMixedDeliveryMessage(plan.evidenceArgs)
}
return toolOutcome{
output: msg,
blocked: true,
errMsg: "blocked: mixed mutation and verification command",
execution: shellPreflightExecution(plan, true),
output: msg,
blocked: true,
errMsg: "blocked: mixed mutation and verification command",
refusalCode: CodeShellMixedMutationVerification,
execution: shellPreflightExecution(plan, true),
}, true
}
if evidence.BashToolCallUsesNonTerminalInlineInterpreter(plan.evidenceArgs) {
msg := evidence.ShellContractInlineNonTerminalMessage(plan.evidenceArgs)
return toolOutcome{
output: msg,
blocked: true,
errMsg: "blocked: non-terminal inline interpreter command",
execution: shellPreflightExecution(plan, false),
output: msg,
blocked: true,
errMsg: "blocked: non-terminal inline interpreter command",
refusalCode: CodeShellInlineInterpreterNotLast,
execution: shellPreflightExecution(plan, false),
}, true
}
}
@@ -154,4 +154,7 @@ func TestToolBeforeFailedWriterReplacementOpensDependencyBarrier(t *testing.T) {
if len(batch.results) != 2 || !strings.Contains(batch.results[1], "skipped because an earlier modification") {
t.Fatalf("dependency results = %+v", batch.results)
}
if got := batch.outcomes[1].refusalCode; got != CodeBatchSkippedAfterFailedChange {
t.Fatalf("the skipped call's refusal code = %q, want %q", got, CodeBatchSkippedAfterFailedChange)
}
}
@@ -0,0 +1,2 @@
- 修复:命令或同一批调用被宿主拦截时(比如“修改和验证混在一起”“前面的修改失败,这一步跳过”),工具卡片现在用中文说明原因,不再显示英文的 blocked 提示。修复 #14652。
- Fixed: when the host stops a command or a call in a batch before it runs, the tool card now explains why in your language instead of an English "blocked:" line. Fixes #14652.