Make tool argument failures recoverable / 让工具参数错误可恢复 (#9863)

* Fix recoverable tool argument validation

Problem: malformed tool arguments were escalated into schema-specific blocking after repeated failures.\n\nRoot cause: schema loop state duplicated the shared storm breaker and converted ordinary validation errors into permission-style blocks.\n\nFix: return actionable unexecuted validation errors, conservatively diagnose redundant argument wrappers, preserve capability resolution boundaries, and let the existing storm breaker provide soft convergence. Remove obsolete schema counters and unlock state, and document the contract.\n\nVerification: go test ./..., go test -race ./internal/agent ./internal/tool ./internal/capability, cd desktop && go test ./..., go vet ./....

* Satisfy argument recovery lint checks

Problem: the new recovery regression loops triggered the repository intrange lint rule.\n\nRoot cause: tests used C-style loops where the index was unnecessary.\n\nFix: use Go integer range loops while retaining the indexed concurrency case.\n\nVerification: make lint-go.

* Correct indexed recovery test loops

Problem: lint cleanup removed indexes still used by recovery assertions.\n\nRoot cause: three loops were converted to index-free ranges without preserving their test identifiers.\n\nFix: retain integer indexes only where assertions and call IDs use them.\n\nVerification: make lint-go.

* Keep mixed batch assertion index

Problem: the mixed permission regression still referenced its loop index after lint cleanup.\n\nRoot cause: the index-free range conversion missed the assertion loop.\n\nFix: retain the index for the third-batch assertion.\n\nVerification: make lint-go.

* Reduce resolver path complexity

Problem: the CI repolint gate reported execute_one.go over its complexity budget after adding repairable resolver diagnostics.\n\nRoot cause: input-error classification was nested inside the already complex proxy resolution owner.\n\nFix: extract proxyResolutionError so repairable input failures and unrelated resolver errors retain their distinct handling without increasing the owner complexity budget.\n\nVerification: go run ./tools/repolint; focused agent tests and race tests.
This commit is contained in:
SivanCola authored and GitHub committed 2026-09-07 00:10:09 +08:00
1 parent 617853a2b7
commit a4ae5ff6b9
15 files changed
+772 -261

No files matched your search

+51
View File
@@ -161,3 +161,54 @@ registry for dispatch. The model lists, inspects, calls, or declines them via
`use_capability` without changing the provider tool list. Task risk changes host
planning, verification, and review policy, not which tools appear on the
provider-visible surface. The retired `connect_tool_source` path is no longer registered.
## Invalid arguments and recovery
The host validates concrete tool arguments before extension interception,
permission prompts, hooks, write leases, subagent execution, or tool dispatch.
An extension replacement is resolved and validated again. Invalid arguments are
an unexecuted tool error, not a permission refusal: correcting the input can
succeed on any subsequent call without an inspect action or a new user turn.
Normal permission and execution checks still apply to a corrected call.
Errors retain the target name, schema fingerprint, violation paths, and
`argument_validation:<tool>:<fingerprint>:<category>` diagnostic signature.
Feedback identifies whether parameters belong at the direct tool's input root
or inside a capability call's `arguments`. A conservative, value-free hint may
identify a single redundant `arguments` wrapper when the inner object satisfies
the concrete contract, including conditional validation. This is advice only:
the host never unwraps, coerces, fills, or executes the supplied parameters as
part of diagnosis. Legitimate `arguments` fields and nested skill contracts are
preserved. Empty/null validation compatibility remains unchanged.
Input errors returned before capability resolution also receive contract
feedback when the outer schema establishes the error. Successful resolution is
not subjected to a new envelope gate; unavailable targets and authorization
errors keep their own reasons. A malformed host schema is a configuration
problem, not something the model can fix by rewriting arguments. Existing
third-party MCP schema-compilation fallback remains unchanged.
`inspect` remains a contract discovery operation, not an unlock requirement.
There is no schema-specific error counter or third-failure tool lock. The shared
storm breaker gives a soft convergence hint after three consecutive equivalent
failed batches; multiple calls in one batch do not add multiple rounds, and a
successful result resets the existing failure streak. Parameter-only failures
receive correction advice rather than instructions about bypassing permissions.
If correction remains unsuccessful, the model may report tool argument
generation failure and unfinished work. This does not mark the work completed.
Real permission, Plan-mode, hook, and write-loop restrictions remain enforced.
Convergence is advisory. With `MaxSteps=0` and no explicit budget, there is no
fixed-round hard stop; configured step/spend limits and cancellation still work.
No extra repair-model request, provider-specific switch, or tool-schema change
is introduced. Feedback is bounded to 4 KiB and appended to the failed tool
result without rewriting prior messages or the stable provider prefix. Additional
feedback consumes context tokens; historical error messages are left intact.
Argument validation/failure/skip/remote-dispatch counters retain their meaning;
internal wrapper checks do not count as additional calls. The legacy
`capability_loop_guard.RepeatFailures` and `BlockedCalls` fields remain in metrics
for compatibility but are no longer incremented by new runs. They are not
repurposed as storm-intervention counters; existing `loop_guard` notices describe
those interventions. No session/config migration is required. Downgrading restores
the older error-recovery behavior without changing the stored conversation.
+35
View File
@@ -123,3 +123,38 @@ registry 中供调度,但不会展开到 top-level provider schema;模型通
记忆写入、workflow 等)仍在 host registry 中可调度;模型通过 `use_capability` 列举、
检查、调用或拒绝它们,且不会改变 provider 工具列表。改变的是宿主根据真实动作建立的
验证义务,而不是 provider 可见工具集合。已退役的 `connect_tool_source` 不再注册。
## 参数错误与恢复
宿主在 extension 拦截、权限审批、hook、写入租约、子代理执行和工具分发前校验真实目标参数。
extension 替换调用后仍须重新解析和校验。参数不合法属于“工具未执行”的普通错误,不是权限拒绝;
修正参数后,任意后续调用都可以再次尝试,无需 inspect 或新用户轮次解锁。
正确调用仍须通过正常的权限与执行检查。
错误保留目标工具名、schema 指纹、违规字段路径和
`argument_validation:<tool>:<fingerprint>:<category>` 诊断标识。
反馈明确参数应位于直接工具的输入根对象,还是 capability 调用的 `arguments` 内。
只有单层包装的内层对象符合真实契约(含条件校验)时,才可能给出不含参数值的多余
`arguments` 包装提示。这只是建议:诊断不会自动拆包、转换类型、补字段或执行参数。
合法的 `arguments` 字段及 skill 嵌套契约保持不变,空值/null 的既有校验兼容也保持不变。
capability 解析前返回的输入错误,在外层 schema 能确认违规时也获得统一反馈。
成功解析的调用不会新增外层校验门;目标不可用和授权错误保留自己的原因。
宿主 schema 编译失败属于配置问题,不要求模型改写参数修复;第三方 MCP 的既有
schema 编译失败回退策略不变。
`inspect` 继续用于查询契约,不再承担解锁职责。schema 专用错误计数和第三次失败锁定已移除。
连续三个等价失败批次由现有通用 storm breaker 给出软性收敛提示;同一批次多个调用不累计为多个轮次,
出现成功结果时按既有行为重置失败序列。只有参数错误时,反馈要求纠正参数,而不是禁止绕过权限。
仍无法纠正时,模型可以说明“工具参数生成失败”及未完成工作,这不代表任务已完成。
真实权限、Plan、hook 和写入循环限制仍然有效。
收敛依靠提示。当 `MaxSteps=0` 且未配置显式预算时,不保证固定轮次内强制停止;
用户配置的轮次/支出限制及取消机制仍然有效。不新增修复模型请求、供应商开关或工具 schema 变化。
错误反馈限制为 4 KiB,追加在失败工具结果中,不重写此前消息或稳定的 provider 前缀。
新增反馈会消耗上下文 token;历史错误文本保留原样。
参数校验、失败、跳过及远程分发计数保持原有含义,内部包装诊断不重复计数。
metrics 中旧的 `capability_loop_guard.RepeatFailures` 和 `BlockedCalls` 字段继续保留兼容,
但新运行不再递增它们,也不将它们重新解释为 storm 干预次数;后者仍使用现有 `loop_guard` Notice。
无需迁移会话或配置;降级会恢复旧版错误恢复行为,但不改变已存储会话。
+520
View File
@@ -0,0 +1,520 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"testing"
"unicode/utf8"
"reasonix/internal/agent/testutil"
"reasonix/internal/capability"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/skill"
"reasonix/internal/tool"
_ "reasonix/internal/tool/builtin"
)
// recoveryArgumentTool uses the production contract without running a shell or
// reading files. Its receipts still travel through the real dispatch pipeline.
type recoveryArgumentTool struct {
name string
schema json.RawMessage
mu sync.Mutex
inputs []string
}
func (t *recoveryArgumentTool) Name() string { return t.name }
func (t *recoveryArgumentTool) Description() string { return "argument recovery fixture" }
func (t *recoveryArgumentTool) Schema() json.RawMessage { return t.schema }
func (t *recoveryArgumentTool) ReadOnly() bool { return true }
func (t *recoveryArgumentTool) Execute(_ context.Context, args json.RawMessage) (string, error) {
t.mu.Lock()
defer t.mu.Unlock()
t.inputs = append(t.inputs, string(args))
return "executed", nil
}
func recoveryBash(t *testing.T) *recoveryArgumentTool {
t.Helper()
b, ok := tool.LookupBuiltin("bash")
if !ok {
t.Fatal("missing bash")
}
return &recoveryArgumentTool{name: "bash", schema: b.Schema()}
}
func TestArgumentErrorsRemainCorrectable(t *testing.T) {
b := recoveryBash(t)
reg := tool.NewRegistry()
reg.Add(b)
audit := &capability.Audit{}
a := New(nil, reg, NewSession(""), Options{CapabilityAudit: audit}, event.Discard)
raw := `{"arguments":{"command":"private-command-marker"}}`
for i := 1; i <= 3; i++ {
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{ID: fmt.Sprint(i), Name: "bash", Arguments: raw})
if out.blocked {
t.Errorf("attempt %d converted invalid input into a host refusal: %s", i, out.output)
}
if !strings.Contains(out.output, "726072279d1c3417") || !strings.Contains(out.output, "required properties: command") {
t.Errorf("missing original diagnostic: %s", out.output)
}
if !strings.Contains(out.output, `sole "arguments" wrapper`) {
t.Errorf("missing wrapper correction: %s", out.output)
}
if strings.Contains(out.output, "private-command-marker") {
t.Fatal("argument value leaked")
}
}
good := `{"command":"private-command-marker"}`
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{ID: "good", Name: "bash", Arguments: good})
if out.errMsg != "" || out.blocked {
t.Fatalf("corrected call: %+v", out)
}
if len(b.inputs) != 1 || b.inputs[0] != good {
t.Fatalf("executions: %v", b.inputs)
}
snap := audit.Snapshot()
if snap.Arguments.Validations != 4 || snap.Arguments.Fail != 3 || snap.Arguments.RemoteDispatch != 0 {
t.Fatalf("audit: %+v", snap.Arguments)
}
if snap.LoopGuard.BlockedCalls != 0 || snap.LoopGuard.RepeatFailures != 0 {
t.Fatalf("retired schema metrics: %+v", snap.LoopGuard)
}
}
func TestArgumentStormEncouragesCorrection(t *testing.T) {
b := recoveryBash(t)
reg := tool.NewRegistry()
reg.Add(b)
a := New(nil, reg, NewSession(""), Options{}, event.Discard)
for i := 1; i <= 3; i++ {
out := executeBatchOutputs(a, context.Background(), []provider.ToolCall{{ID: fmt.Sprint(i), Name: "bash", Arguments: `{"arguments":{"command":"pwd"}}`}})[0]
if strings.Contains(out, "Do not retry by rewriting arguments") || strings.Contains(out, "do not keep retrying a blocked tool") {
t.Fatalf("permission-style advice: %s", out)
}
if i == 3 && !strings.Contains(out, "tool argument generation failed") {
t.Errorf("missing parameter convergence advice: %s", out)
}
}
if !a.turn.loopGuardArmed {
t.Fatal("soft final exit not armed")
}
out := executeBatchOutputs(a, context.Background(), []provider.ToolCall{{ID: "good", Name: "bash", Arguments: `{"command":"pwd"}`}})[0]
if out != "executed" || len(b.inputs) != 1 {
t.Fatalf("corrected call output %q inputs %v", out, b.inputs)
}
}
func recoveryProxy(reg *tool.Registry) *UseCapabilityTool {
p := NewUseCapabilityTool(context.Background(), nil, nil, reg, nil, nil, func() capability.Catalog {
return capability.Catalog{Entries: []capability.Entry{{ID: "tool:bash", Kind: capability.KindTool, Name: "bash"}}}
})
reg.Add(p)
return p
}
func TestArgumentRecoveryProxyLayers(t *testing.T) {
tests := []struct {
name, raw, want string
validation bool
}{
{"outer wrapper", `{"arguments":{"action":"call","capability_id":"tool:bash","arguments":{"command":"pwd"}}}`, "parameters for use_capability directly at the root", true},
{"missing action", `{}`, "required properties: action", true},
{"action type", `{"action":123}`, "expected string", true},
{"invalid limit", `{"action":"search","query":"bash","limit":10}`, "argument validation failed", true},
{"missing target", `{"action":"call"}`, "capability_id is required", false},
{"missing query", `{"action":"search"}`, "query is required", false},
{"missing inspect target", `{"action":"inspect"}`, "capability_id is required", false},
{"bad MCP object", `{"action":"call","capability_id":"mcp-tool:missing/test","arguments":"{}"}`, "/arguments", true},
{"inner wrapper", `{"action":"call","capability_id":"tool:bash","arguments":{"arguments":{"command":"pwd"}}}`, "inside use_capability.arguments", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
b := recoveryBash(t)
reg := tool.NewRegistry()
reg.Add(b)
recoveryProxy(reg)
audit := &capability.Audit{}
a := New(nil, reg, NewSession(""), Options{CapabilityAudit: audit}, event.Discard)
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{ID: "bad", Name: "use_capability", Arguments: tc.raw})
if out.blocked || !strings.Contains(out.output, tc.want) || !strings.Contains(out.output, "not executed") {
t.Fatalf("outcome: %+v", out)
}
if len(b.inputs) != 0 {
t.Fatal("invalid call executed")
}
if strings.HasPrefix(out.errMsg, "argument_validation:") != tc.validation {
t.Fatalf("wrong error class %q", out.errMsg)
}
snap := audit.Snapshot()
want := 0
if tc.validation {
want = 1
}
if snap.Arguments.Fail != want || snap.Arguments.Validations != want {
t.Fatalf("diagnostic counted more than once: %+v", snap.Arguments)
}
good := `{"action":"call","capability_id":"tool:bash","arguments":{"command":"pwd"},"ignored_legacy_field":true}`
out = a.executeOne(context.Background(), &a.turn, provider.ToolCall{ID: "good", Name: "use_capability", Arguments: good})
if out.errMsg != "" || len(b.inputs) != 1 || b.inputs[0] != `{"command":"pwd"}` {
t.Fatalf("successful envelope compatibility: %+v, %v", out, b.inputs)
}
})
}
}
func TestArgumentRecoveryWrapperHintIsConservative(t *testing.T) {
simple := `{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}`
tests := []struct {
name, schema, raw string
hint bool
}{
{"confirmed", simple, `{"arguments":{"command":"sensitive-value"}}`, true},
{"empty", simple, ``, false}, {"null", simple, `null`, false},
{"array", simple, `[]`, false}, {"string", simple, `"{}"`, false},
{"truncated", simple, `{"arguments":`, false},
{"multiple values", simple, `{"arguments":{"command":"secret"}} {}`, false},
{"null inner", simple, `{"arguments":null}`, false},
{"array inner", simple, `{"arguments":[]}`, false},
{"string inner", simple, `{"arguments":"{\"command\":\"secret\"}"}`, false},
{"invalid inner", simple, `{"arguments":{"command":42}}`, false},
{"recursive wrapping", simple, `{"arguments":{"arguments":{"command":"secret"}}}`, false},
{"outer sibling", simple, `{"arguments":{"command":"secret"},"extra":true}`, false},
{"legitimate arguments", `{"type":"object","properties":{"arguments":{"type":"object"},"command":{"type":"string"}},"required":["command"]}`, `{"arguments":{"command":"secret"}}`, false},
{"composition", `{"type":"object","properties":{"command":{"type":"string"}},"allOf":[{"required":["command"]}]}`, `{"arguments":{"command":"secret"}}`, false},
{"reference", `{"type":"object","properties":{"command":{"type":"string"}},"$defs":{"args":{"required":["command"]}},"$ref":"#/$defs/args"}`, `{"arguments":{"command":"secret"}}`, false},
{"pattern", `{"type":"object","properties":{"command":{"type":"string"}},"patternProperties":{"arguments":{}},"required":["command"]}`, `{"arguments":{"command":"secret"}}`, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
target := &recoveryArgumentTool{name: "fixture", schema: json.RawMessage(tc.schema)}
raw := json.RawMessage(tc.raw)
before := string(raw)
result := tool.ValidateArguments(target, raw)
if result.CompileErr != nil || len(result.Violations) == 0 {
t.Fatalf("invalid test fixture: %+v", result)
}
msg := argumentValidationMessage(&toolCallPlan{permName: target.Name(), execTool: target, execArgs: raw}, result)
if strings.Contains(msg, `sole "arguments" wrapper`) != tc.hint {
t.Fatalf("hint=%v: %s", tc.hint, msg)
}
if string(raw) != before || len(target.inputs) != 0 {
t.Fatal("diagnostics changed or executed arguments")
}
if strings.Contains(msg, "sensitive-value") || strings.Contains(msg, "secret") {
t.Fatal("argument value leaked")
}
})
}
}
type conditionalRecoveryTool struct{ *recoveryArgumentTool }
func (t conditionalRecoveryTool) ValidateArguments(raw json.RawMessage) []tool.ArgumentViolation {
return []tool.ArgumentViolation{{Path: "/command", Keyword: "conditional", Expected: "an eligible command"}}
}
func TestArgumentWrapperHintRespectsConditionalValidation(t *testing.T) {
target := conditionalRecoveryTool{recoveryBash(t)}
raw := json.RawMessage(`{"arguments":{"command":"pwd"}}`)
result := tool.ValidateArguments(target, raw)
msg := argumentValidationMessage(&toolCallPlan{permName: "bash", execTool: target, execArgs: raw}, result)
if strings.Contains(msg, `sole "arguments" wrapper`) {
t.Fatalf("conditional rejection ignored: %s", msg)
}
}
func TestArgumentRecoveryStillChecksPermissions(t *testing.T) {
b := recoveryBash(t)
reg := tool.NewRegistry()
reg.Add(b)
gate := &stubGate{deny: map[string]bool{"bash": true}}
a := New(nil, reg, NewSession(""), Options{Gate: gate}, event.Discard)
for range 3 {
a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "bash", Arguments: `{}`})
}
if len(gate.checked) != 0 {
t.Fatal("invalid arguments reached permission gate")
}
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "bash", Arguments: `{"command":"pwd"}`})
if !out.blocked || len(gate.checked) != 1 || len(b.inputs) != 0 {
t.Fatalf("permission bypass: %+v", out)
}
}
func TestArgumentRecoveryConcurrentFailures(t *testing.T) {
b := recoveryBash(t)
reg := tool.NewRegistry()
reg.Add(b)
audit := &capability.Audit{}
a := New(nil, reg, NewSession(""), Options{CapabilityAudit: audit}, event.Discard)
var wg sync.WaitGroup
for i := range 32 {
wg.Add(1)
go func(i int) {
defer wg.Done()
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{ID: fmt.Sprint(i), Name: "bash", Arguments: `{"arguments":{"command":"pwd"}}`})
if out.blocked || !strings.Contains(out.output, "required properties: command") {
t.Errorf("call %d: %+v", i, out)
}
}(i)
}
wg.Wait()
if len(b.inputs) != 0 || audit.Snapshot().Arguments.Fail != 32 {
t.Fatal("concurrent failure accounting or execution")
}
}
func TestArgumentRecoveryCompileFailure(t *testing.T) {
for _, mixed := range []bool{false, true} {
t.Run(fmt.Sprint("mixed=", mixed), func(t *testing.T) {
target := &recoveryArgumentTool{name: "broken", schema: json.RawMessage(`{"type":"invalid-schema-type"}`)}
reg := tool.NewRegistry()
reg.Add(target)
reg.Add(recoveryBash(t))
a := New(nil, reg, NewSession(""), Options{}, event.Discard)
for i := range 3 {
calls := []provider.ToolCall{{ID: fmt.Sprint(i), Name: "broken", Arguments: `{}`}}
if mixed {
calls = append(calls, provider.ToolCall{ID: fmt.Sprint(i, "bad"), Name: "bash", Arguments: `{}`})
}
out := executeBatchOutputs(a, context.Background(), calls)[0]
if !strings.Contains(out, "host configuration error") || strings.Contains(out, "tool argument generation failed") || strings.Contains(out, "otherwise fix the arguments") {
t.Fatalf("schema failure treated as input mistake: %s", out)
}
}
})
}
}
func TestArgumentRecoveryMessageBound(t *testing.T) {
msg := truncateValidationMessage(strings.Repeat("中", maxArgumentValidationMessageBytes))
if len(msg) > maxArgumentValidationMessageBytes || !utf8.ValidString(msg) || !strings.HasSuffix(msg, "[truncated]") {
t.Fatal("invalid UTF-8 or oversized diagnostic")
}
}
func TestArgumentRecoveryNullProxyAndResolutionErrors(t *testing.T) {
reg := tool.NewRegistry()
noop := &recoveryArgumentTool{name: "noop", schema: json.RawMessage(`{"type":"object"}`)}
reg.Add(noop)
recoveryProxy(reg)
a := New(nil, reg, NewSession(""), Options{}, event.Discard)
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "use_capability", Arguments: `{"action":"call","capability_id":"tool:noop","arguments":null,"ignored":true}`})
if out.errMsg != "" || len(noop.inputs) != 1 || noop.inputs[0] != "null" {
t.Fatalf("null compatibility: %+v, %v", out, noop.inputs)
}
out = a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "use_capability", Arguments: `{"action":"call","capability_id":"tool:absent"}`})
if !strings.Contains(out.output, `tool "absent" is not registered`) || strings.HasPrefix(out.errMsg, "argument_validation:") {
t.Fatalf("resolution error lost: %+v", out)
}
}
func TestArgumentRecoveryDoesNotMaskResolverPolicyFailure(t *testing.T) {
reg := tool.NewRegistry()
ledger := capability.NewLedger()
ledger.SeedCandidates(capability.RouteDecision{Candidates: []capability.RouteCandidate{{Entry: capability.Entry{ID: "tool:bash"}, Policy: capability.AutoUseRequire}}})
proxy := NewUseCapabilityTool(context.Background(), nil, nil, reg, ledger, nil, nil)
reg.Add(proxy)
audit := &capability.Audit{}
a := New(nil, reg, NewSession(""), Options{CapabilityAudit: audit}, event.Discard)
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "use_capability", Arguments: `{"action":"decline","capability_id":"tool:bash","reason":"skip","unknown_field":true}`})
if !strings.Contains(out.output, "cannot decline a require capability") || strings.HasPrefix(out.errMsg, "argument_validation:") || audit.Snapshot().Arguments.Validations != 0 {
t.Fatalf("policy error masked by outer schema: %+v", out)
}
}
func TestArgumentRecoverySkillNesting(t *testing.T) {
store := skillStoreWithArchitect(t)
calls := 0
reg := tool.NewRegistry()
reg.Add(skill.NewRunSkillTool(store, func(_ context.Context, _ skill.Skill, input string, _ skill.SubagentRunOptions) (string, error) {
calls++
if input != "specific task" {
t.Errorf("skill input changed: %q", input)
}
return "skill executed", nil
}))
reg.Add(NewUseCapabilityTool(context.Background(), nil, nil, reg, nil, nil, func() capability.Catalog {
return capability.Catalog{Entries: []capability.Entry{{ID: "skill:team-architect", Kind: capability.KindSkill, Name: "team-architect"}}}
}))
a := New(nil, reg, NewSession(""), Options{}, event.Discard)
for range 3 {
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "use_capability", Arguments: `{"action":"call","capability_id":"skill:team-architect","arguments":{}}`})
if out.blocked || !strings.Contains(out.output, `"arguments":{"arguments"`) || strings.Contains(out.output, `sole "arguments" wrapper`) {
t.Fatalf("skill correction: %+v", out)
}
}
out := a.executeOne(withNoClosedLoop(context.Background()), &a.turn, provider.ToolCall{Name: "use_capability", Arguments: `{"action":"call","capability_id":"skill:team-architect","arguments":{"arguments":"specific task"}}`})
if out.errMsg != "" || calls != 1 {
t.Fatalf("skill nested call: %+v calls %d", out, calls)
}
}
func TestArgumentStormBatchCountsAndSuccessReset(t *testing.T) {
reg := tool.NewRegistry()
b := recoveryBash(t)
reg.Add(b)
reader := &recoveryArgumentTool{name: "read_file", schema: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}`)}
reg.Add(reader)
a := New(nil, reg, NewSession(""), Options{}, event.Discard)
bad := []provider.ToolCall{{ID: "b", Name: "bash", Arguments: `{}`}, {ID: "r", Name: "read_file", Arguments: `{}`}}
for i := 1; i <= 3; i++ {
batch := a.executeBatch(context.Background(), &a.turn, bad)
if (strings.Contains(batch.results[0], "tool argument generation failed")) != (i == 3) {
t.Fatalf("batch %d: %v", i, batch.results)
}
for _, out := range batch.outcomes {
if out.blocked {
t.Fatal("parameter batch became blocked")
}
}
}
mixed := []provider.ToolCall{{ID: "b2", Name: "bash", Arguments: `{}`}, {ID: "r2", Name: "read_file", Arguments: `{"path":"file.go"}`}}
batch := a.executeBatch(context.Background(), &a.turn, mixed)
if len(reader.inputs) != 1 || a.turn.stormCount != 0 {
t.Fatalf("mixed success did not reset: %v", batch.results)
}
batch = a.executeBatch(context.Background(), &a.turn, bad)
if strings.Contains(batch.results[0], "tool argument generation failed") {
t.Fatal("success failed to break the streak")
}
}
func TestArgumentStormMixedPermissionFailure(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(recoveryBash(t))
reg.Add(&recoveryArgumentTool{name: "denied", schema: json.RawMessage(`{"type":"object"}`)})
a := New(nil, reg, NewSession(""), Options{Gate: &stubGate{deny: map[string]bool{"denied": true}}}, event.Discard)
for i := range 3 {
batch := a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{{Name: "bash", Arguments: `{}`}, {Name: "denied", Arguments: `{}`}})
if batch.outcomes[0].blocked || !batch.outcomes[1].blocked {
t.Fatalf("per-call classification lost: %+v", batch.outcomes)
}
if i == 2 && (!strings.Contains(batch.results[0], "Respect the permission") || !strings.Contains(batch.results[0], "required properties: command") || !strings.Contains(batch.results[1], "denied")) {
t.Fatalf("mixed batch lost its errors: %v", batch.results)
}
}
}
// Capture serialized messages at request time, before later rounds can mutate
// session state, so this checks real prefix preservation rather than aliases.
type recoveryCaptureProvider struct {
*testutil.MockProvider
messages [][]json.RawMessage
tools [][]byte
cancel context.CancelFunc
}
func (p *recoveryCaptureProvider) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
raw, _ := json.Marshal(req.Messages)
var messages []json.RawMessage
_ = json.Unmarshal(raw, &messages)
p.messages = append(p.messages, messages)
schema, _ := json.Marshal(req.Tools)
p.tools = append(p.tools, schema)
if p.cancel != nil && len(p.messages) == 7 {
p.cancel()
return nil, ctx.Err()
}
return p.MockProvider.Stream(ctx, req)
}
func TestArgumentRecoveryPreservesProviderPrefix(t *testing.T) {
b := recoveryBash(t)
reg := tool.NewRegistry()
reg.Add(b)
var turns []testutil.Turn
raw := `{"arguments":{"command":"pwd"}}`
for i := range 3 {
turns = append(turns, testutil.Turn{ToolCalls: []provider.ToolCall{{ID: fmt.Sprint(i), Name: "bash", Arguments: raw}}})
}
turns = append(turns, testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "fixed", Name: "bash", Arguments: `{"command":"pwd"}`}}}, testutil.Turn{Text: "done"})
p := &recoveryCaptureProvider{MockProvider: testutil.NewMock("recovery", turns...)}
sink := &recordSink{}
a := New(p, reg, NewSession("Stable system prefix"), Options{MaxSteps: 0}, sink)
if err := a.Run(withNoClosedLoop(context.Background()), "Run the fixture"); err != nil {
t.Fatal(err)
}
if len(p.messages) != 5 || len(b.inputs) != 1 {
t.Fatalf("requests=%d executions=%d", len(p.messages), len(b.inputs))
}
for i := 1; i < len(p.messages); i++ {
if !bytes.Equal(p.tools[0], p.tools[i]) {
t.Fatal("tool schema changed during correction")
}
for j, old := range p.messages[i-1] {
if j >= len(p.messages[i]) || !bytes.Equal(old, p.messages[i][j]) {
t.Fatalf("existing prefix rewritten at request %d message %d", i, j)
}
}
}
results := map[string]int{}
originals := 0
for _, m := range a.Session().Snapshot() {
if m.Role == provider.RoleTool {
results[m.ToolCallID]++
}
for _, c := range m.ToolCalls {
if c.ID != "fixed" {
if c.Arguments != raw {
t.Fatalf("original malformed call rewritten: %+v", c)
}
originals++
}
}
}
if originals != 3 || len(results) != 4 {
t.Fatalf("history originals=%d results=%v", originals, results)
}
for _, n := range results {
if n != 1 {
t.Fatal("duplicate tool result")
}
}
if len(sink.kinds(event.Retrying)) != 0 {
t.Fatal("tool correction invoked network retry")
}
}
func TestArgumentRecoverySoftLimitAndExplicitBudget(t *testing.T) {
for _, limit := range []int{0, 3} {
t.Run(fmt.Sprint(limit), func(t *testing.T) {
b := recoveryBash(t)
reg := tool.NewRegistry()
reg.Add(b)
turns := make([]testutil.Turn, 12)
for i := range turns {
turns[i] = testutil.Turn{ToolCalls: []provider.ToolCall{{ID: fmt.Sprint(i), Name: "bash", Arguments: `{}`}}}
}
p := &recoveryCaptureProvider{MockProvider: testutil.NewMock("recovery", turns...)}
ctx, cancel := context.WithCancel(withNoClosedLoop(context.Background()))
defer cancel()
if limit == 0 {
p.cancel = cancel
}
a := New(p, reg, NewSession("system"), Options{MaxSteps: limit}, event.Discard)
err := a.Run(ctx, "Use the fixture")
if len(b.inputs) != 0 {
t.Fatal("invalid calls executed")
}
if limit == 0 {
if ctx.Err() == nil || len(p.messages) != 7 {
t.Fatalf("unconfigured run stopped before cancellation: %v requests=%d", err, len(p.messages))
}
if !a.turn.loopGuardArmed {
t.Fatal("soft convergence never activated")
}
} else {
var pause *maxStepsPause
if !errors.As(err, &pause) {
t.Fatalf("explicit steps failed to pause: %v", err)
}
}
})
}
}
+94 -34
View File
@@ -5,7 +5,9 @@ import (
"errors"
"fmt"
"strings"
"unicode/utf8"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
@@ -33,48 +35,57 @@ func (a *Agent) applyArgumentValidation(plan *toolCallPlan) (toolOutcome, bool)
if a.capabilityAudit != nil {
a.capabilityAudit.RecordArgumentValidation(failed, result.Skipped, false)
}
if result.Skipped {
if result.Skipped || (result.CompileErr == nil && len(result.Violations) == 0) {
return toolOutcome{}, false
}
if result.CompileErr != nil {
msg := fmt.Sprintf("host configuration error: tool %q has an invalid argument schema (schema fingerprint %s); execution was not dispatched", plan.permName, shortSchemaFingerprint(result.Fingerprint))
a.noteCapabilityInvocation(plan.call.Name, json.RawMessage(plan.call.Arguments), errors.New(msg))
return toolOutcome{output: "error: " + msg, errMsg: argumentValidationSignature(plan.permName, result.Fingerprint, "schema")}, true
}
if len(result.Violations) == 0 {
a.clearSchemaError(plan.permName, result.Fingerprint)
return toolOutcome{}, false
}
sig := argumentValidationSignature(plan.permName, result.Fingerprint, result.Violations[0].Keyword)
msg, blocked := a.schemaErrorContract(plan, result, sig)
a.noteCapabilityInvocation(plan.call.Name, json.RawMessage(plan.call.Arguments), errors.New(msg))
return toolOutcome{output: msg, blocked: blocked, errMsg: sig}, true
return a.argumentValidationFailure(plan, result), true
}
func hostValidateBeforeDispatch(target tool.Tool, args json.RawMessage) (bool, string) {
result := tool.ValidateArguments(target, args)
if result.Skipped {
return false, ""
// argumentValidationFailure reports an unexecuted call, never a permission
// refusal. Repeated failures are owned by the batch storm breaker.
func (a *Agent) argumentValidationFailure(plan *toolCallPlan, result tool.ArgumentValidationResult) toolOutcome {
category := "schema"
if result.CompileErr == nil {
category = result.Violations[0].Keyword
}
if result.CompileErr != nil {
return true, fmt.Sprintf("host configuration error: tool %q has an invalid argument schema (schema fingerprint %s); execution was not dispatched", target.Name(), shortSchemaFingerprint(result.Fingerprint))
}
if len(result.Violations) == 0 {
return false, ""
}
var b strings.Builder
fmt.Fprintf(&b, "argument validation failed for %q (schema fingerprint %s; remote_dispatched=false):", target.Name(), shortSchemaFingerprint(result.Fingerprint))
for _, violation := range result.Violations {
path := violation.Path
if path == "" {
path = "/"
msg := argumentValidationMessage(plan, result)
a.noteCapabilityInvocation(plan.call.Name, json.RawMessage(plan.call.Arguments), errors.New(msg))
return toolOutcome{output: msg, errMsg: argumentValidationSignature(plan.permName, result.Fingerprint, category)}
}
// diagnoseCapabilityInputFailure runs only after the resolver identifies an
// input error. Successful resolution and unavailable/authorization errors keep
// their historical behavior, even if an ignored envelope field is invalid.
func (a *Agent) diagnoseCapabilityInputFailure(plan *toolCallPlan, err error) toolOutcome {
result := tool.ValidateArguments(plan.tool, json.RawMessage(plan.call.Arguments))
if !result.Skipped && (result.CompileErr != nil || len(result.Violations) > 0) {
if a.capabilityAudit != nil {
a.capabilityAudit.RecordArgumentValidation(true, false, false)
}
fmt.Fprintf(&b, "\n- %s: %s; expected %s", path, violation.Keyword, violation.Expected)
return a.argumentValidationFailure(plan, result)
}
return true, truncateValidationMessage(b.String())
return toolOutcome{
output: truncateValidationMessage(fmt.Sprintf("error: %v\nThe capability call was not executed. Correct the indicated input and retry; normal permission checks still apply.", err)),
errMsg: firstLine(err.Error()),
}
}
func hostValidateBeforeDispatch(target tool.Tool, args json.RawMessage, capabilityID string) (bool, string) {
result := tool.ValidateArguments(target, args)
if result.Skipped || (result.CompileErr == nil && len(result.Violations) == 0) {
return false, ""
}
return true, argumentValidationMessage(&toolCallPlan{
permName: target.Name(), execTool: target, execArgs: args,
call: provider.ToolCall{Name: "use_capability"},
resolved: tool.ResolvedCall{CapabilityID: capabilityID},
}, result)
}
func argumentValidationMessage(plan *toolCallPlan, result tool.ArgumentValidationResult) string {
if result.CompileErr != nil {
return truncateValidationMessage(fmt.Sprintf("host configuration error: tool %q has an invalid argument schema (schema fingerprint %s); execution was not dispatched. The host schema must be corrected; rewriting call arguments cannot fix it.", plan.permName, shortSchemaFingerprint(result.Fingerprint)))
}
var b strings.Builder
fmt.Fprintf(&b, "argument validation failed for %q (schema fingerprint %s; remote_dispatched=false):", plan.permName, shortSchemaFingerprint(result.Fingerprint))
for _, violation := range result.Violations {
@@ -85,18 +96,63 @@ func argumentValidationMessage(plan *toolCallPlan, result tool.ArgumentValidatio
fmt.Fprintf(&b, "\n- %s: %s; expected %s", path, violation.Keyword, violation.Expected)
}
if id := strings.TrimSpace(plan.resolved.CapabilityID); id != "" {
fmt.Fprintf(&b, "\nThe target was not executed. Correct the target parameters inside %s.arguments; keep the outer capability call envelope.", plan.call.Name)
if strings.HasPrefix(id, "skill:") && plan.permName == "run_skill" {
b.WriteString("\nUse this exact nested call shape:\n")
b.WriteString(`{"action":"call","capability_id":"`)
b.WriteString(escapeJSONString(id))
b.WriteString(`","arguments":{"arguments":"specific review or implementation task"}}`)
} else {
fmt.Fprintf(&b, "\nInspect %q for its exact argument schema, then retry action=call once with a JSON object matching it.", id)
fmt.Fprintf(&b, "\nInspect %q for its exact argument schema, if needed, then retry action=call with a JSON object matching it.", id)
}
} else {
fmt.Fprintf(&b, "\nThe call was not executed. Pass the parameters for %s directly at the root of its input object, correct the indicated errors and retry.", plan.permName)
}
b.WriteString("\nNormal permission checks still apply.")
if hasRedundantArgumentWrapper(plan.execTool, plan.execArgs) {
b.WriteString("\nThe sole \"arguments\" wrapper does not match this tool's schema; its inner object matches the expected parameters. Remove that one wrapper from the target parameters when retrying; keep any outer capability call envelope.")
}
return truncateValidationMessage(b.String())
}
// hasRedundantArgumentWrapper is a conservative, value-free hint, not a
// transformation. Call only after the original arguments failed validation.
func hasRedundantArgumentWrapper(target tool.Tool, raw json.RawMessage) bool {
if target == nil {
return false
}
var schema map[string]json.RawMessage
if json.Unmarshal(target.Schema(), &schema) != nil || string(schema["type"]) != `"object"` {
return false
}
for _, key := range []string{"$ref", "$dynamicRef", "$recursiveRef", "allOf", "anyOf", "oneOf", "not", "if", "then", "else", "patternProperties", "dependencies", "dependentSchemas"} {
if _, exists := schema[key]; exists {
return false
}
}
var props map[string]json.RawMessage
if json.Unmarshal(schema["properties"], &props) != nil || props == nil {
return false
}
if _, exists := props["arguments"]; exists {
return false
}
var outer map[string]json.RawMessage
if json.Unmarshal(raw, &outer) != nil || len(outer) != 1 {
return false
}
inner, exists := outer["arguments"]
if !exists {
return false
}
var object map[string]json.RawMessage
if json.Unmarshal(inner, &object) != nil || object == nil {
return false
}
result := tool.ValidateArguments(target, inner)
return !result.Skipped && result.CompileErr == nil && len(result.Violations) == 0
}
func argumentValidationSignature(target, fingerprint, category string) string {
return "argument_validation:" + target + ":" + shortSchemaFingerprint(fingerprint) + ":" + category
}
@@ -120,5 +176,9 @@ func truncateValidationMessage(message string) string {
if len(message) <= maxArgumentValidationMessageBytes {
return message
}
return message[:maxArgumentValidationMessageBytes-len("\n[truncated]")] + "\n[truncated]"
end := maxArgumentValidationMessageBytes - len("\n[truncated]")
for end > 0 && !utf8.RuneStart(message[end]) {
end--
}
return message[:end] + "\n[truncated]"
}
@@ -3,11 +3,9 @@ package agent
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
@@ -106,77 +104,6 @@ func TestUnavailableMCPCallPreservesResolutionReason(t *testing.T) {
}
}
func TestSchemaErrorThirdCallIsBlocked(t *testing.T) {
store := skillStoreWithArchitect(t)
reg := tool.NewRegistry()
reg.Add(skill.NewRunSkillTool(store, func(context.Context, skill.Skill, string, skill.SubagentRunOptions) (string, error) {
t.Fatal("must not start")
return "", nil
}))
proxy := NewUseCapabilityTool(context.Background(), nil, nil, reg, nil, nil, func() capability.Catalog {
return capability.Catalog{Entries: []capability.Entry{{ID: "skill:team-architect", Kind: capability.KindSkill, Name: "team-architect"}}}
})
reg.Add(proxy)
a := New(nil, reg, NewSession("sys"), Options{}, event.Discard)
call := provider.ToolCall{Name: "use_capability", Arguments: `{"action":"call","capability_id":"skill:team-architect","arguments":{}}`}
var last toolOutcome
for i := 1; i <= 3; i++ {
call.ID = "c" + string(rune('0'+i))
last = a.executeOne(context.Background(), &a.turn, call)
}
if !last.blocked || !strings.Contains(last.output, "3 times") {
t.Fatalf("third call = %+v", last)
}
}
func TestSuccessfulInspectUnlocksSchemaLoopGuard(t *testing.T) {
store := skillStoreWithArchitect(t)
reg := tool.NewRegistry()
reg.Add(skill.NewRunSkillTool(store, func(context.Context, skill.Skill, string, skill.SubagentRunOptions) (string, error) {
t.Fatal("must not start")
return "", nil
}))
proxy := NewUseCapabilityTool(context.Background(), nil, nil, reg, nil, nil, func() capability.Catalog {
return capability.Catalog{Entries: []capability.Entry{{ID: "skill:team-architect", Kind: capability.KindSkill, Name: "team-architect"}}}
})
reg.Add(proxy)
a := New(nil, reg, NewSession("sys"), Options{}, event.Discard)
bad := provider.ToolCall{Name: "use_capability", Arguments: `{"action":"call","capability_id":"skill:team-architect","arguments":{}}`}
for i := range 3 {
bad.ID = fmt.Sprintf("bad-%d", i)
_ = a.executeOne(context.Background(), &a.turn, bad)
}
inspected := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
ID: "inspect", Name: "use_capability", Arguments: `{"action":"inspect","capability_id":"skill:team-architect"}`,
})
if inspected.errMsg != "" || !strings.Contains(inspected.output, "input_schema") {
t.Fatalf("inspect = %+v", inspected)
}
bad.ID = "after-inspect"
after := a.executeOne(context.Background(), &a.turn, bad)
if after.blocked || strings.Contains(after.output, "3 times") {
t.Fatalf("inspect did not unlock schema guard: %+v", after)
}
}
func TestSchemaErrorIncrementIsAtomic(t *testing.T) {
var state turnLoopState
const workers = 64
var done sync.WaitGroup
done.Add(workers)
for range workers {
go func() {
defer done.Done()
state.incrementSchemaError("sig", "skill:x")
}()
}
done.Wait()
record := state.incrementSchemaError("sig", "skill:x")
if record.count != workers+1 {
t.Fatalf("count = %d, want %d", record.count, workers+1)
}
}
func requiredQueryMCPServer(t *testing.T, toolCalls *atomic.Int32) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+12 -4
View File
@@ -3,6 +3,7 @@ package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -214,10 +215,7 @@ func (a *Agent) applyPlanModeAndProxy(ctx context.Context, plan *toolCallPlan) (
if resolver, ok := t.(tool.CallResolver); ok {
rc, rerr := resolver.ResolveCall(ctx, json.RawMessage(call.Arguments))
if rerr != nil {
return toolOutcome{
output: fmt.Sprintf("error: %v", rerr),
errMsg: firstLine(rerr.Error()),
}, true
return a.proxyResolutionError(plan, rerr), true
}
plan.resolved = rc
plan.resolvedMeta = &plan.resolved
@@ -362,6 +360,16 @@ func (a *Agent) applyDeliveryPolicyGates(turn *turnRuntime, plan *toolCallPlan)
return toolOutcome{}, false
}
// proxyResolutionError preserves non-input resolver failures while diagnosing
// only the private, repairable envelope errors marked by the resolver.
func (a *Agent) proxyResolutionError(plan *toolCallPlan, err error) toolOutcome {
var inputErr *capabilityInputError
if errors.As(err, &inputErr) {
return a.diagnoseCapabilityInputFailure(plan, err)
}
return toolOutcome{output: fmt.Sprintf("error: %v", err), errMsg: firstLine(err.Error())}
}
// applyRecoveryAndPermission runs Auto Guard then ordinary permission. Neitheracquires a write lease;
// thathappens only after permission in prepare.
func (a *Agent) applyRecoveryAndPermission(ctx context.Context, plan *toolCallPlan) (toolOutcome, bool) {
@@ -8,6 +8,7 @@ import (
"reasonix/internal/event"
"reasonix/internal/extension"
"reasonix/internal/extension/dispatch"
"reasonix/internal/extension/protocol"
"reasonix/internal/provider"
"reasonix/internal/tool"
@@ -39,3 +40,27 @@ func TestInvalidArgumentsDoNotReachToolBeforeExtension(t *testing.T) {
t.Fatalf("tool.before sidecar calls = %d, want 0 before valid arguments", n)
}
}
func TestInvalidReplacementArgumentsRemainCorrectable(t *testing.T) {
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
if ev == protocol.EventToolBefore {
return replaceWith(t, dispatch.ToolBeforePayload{Name: "read_file", Arguments: `{"arguments":{"path":"private-file"}}`}), nil
}
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
}}
d := newExtDispatcher(client, true, nil, extension.PointToolBefore)
rec := &strictRecordingTool{recordingTool: recordingTool{name: "read_file", readOnly: true}}
reg := tool.NewRegistry()
reg.Add(rec)
gate := &stubGate{}
a := New(nil, reg, NewSession(""), Options{Extensions: d, Gate: gate}, event.Discard)
for range 3 {
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "read_file", Arguments: `{"path":"original"}`})
if out.blocked || !strings.Contains(out.output, `sole "arguments" wrapper`) {
t.Fatalf("replacement error: %+v", out)
}
}
if rec.execs != 0 || len(gate.checked) != 0 {
t.Fatal("invalid replacement reached execution or permission")
}
}
-3
View File
@@ -12,9 +12,6 @@ import (
// proxy policy resolution small enough to audit as one deterministic gate.
func (a *Agent) resolvedSkipOutcome(plan *toolCallPlan, resolved tool.ResolvedCall) toolOutcome {
call := plan.call
if resolved.ProxyAction == "inspect" {
a.clearSchemaErrorsAfterInspect(resolved.CapabilityID)
}
// A connected mcp-server call completes during resolution by listing its
// live tools, so account for that successful call here too.
if resolved.ProxyAction == "call" && !resolved.Unavailable {
-75
View File
@@ -1,75 +0,0 @@
package agent
import (
"context"
"strings"
"reasonix/internal/tool"
)
type schemaErrorRecord struct {
count int
inspectAttached bool
}
func (a *Agent) schemaErrorContract(plan *toolCallPlan, result tool.ArgumentValidationResult, sig string) (string, bool) {
record := a.turn.loop.incrementSchemaError(sig, strings.TrimSpace(plan.resolved.CapabilityID))
msg := argumentValidationMessage(plan, result)
switch {
case record.count == 2 && !record.inspectAttached:
if summary := a.schemaInspectSummary(plan); summary != "" {
msg += "\n\nHost attached inspect summary after a repeated schema error:\n" + summary
a.turn.loop.markSchemaInspectAttached(sig)
}
case record.count >= 3:
msg = fmtSchemaBlockMessage(plan.permName, shortSchemaFingerprint(result.Fingerprint))
if a.capabilityAudit != nil {
a.capabilityAudit.RecordLoopGuard("blocked")
}
return msg, true
}
if a.capabilityAudit != nil {
a.capabilityAudit.RecordLoopGuard("repeat_failure")
}
return msg, false
}
func fmtSchemaBlockMessage(name, fingerprint string) string {
return "blocked: [loop guard] " + name + " failed argument validation 3 times with the same schema fingerprint " + fingerprint +
". Do not retry by rewriting arguments. Inspect the exact capability (or wait for a schema change / new user turn) before calling it again."
}
func (a *Agent) schemaInspectSummary(plan *toolCallPlan) string {
id := strings.TrimSpace(plan.resolved.CapabilityID)
if id == "" || a.svc.tools == nil {
return ""
}
proxy, ok := a.svc.tools.Get("use_capability")
if !ok {
return ""
}
uc, ok := proxy.(*UseCapabilityTool)
if !ok {
return ""
}
out, err := uc.inspect(context.Background(), id)
if err != nil {
return ""
}
if len(out) > 2048 {
out = out[:2048] + "\n[inspect truncated]"
}
return out
}
func (a *Agent) clearSchemaError(name, fingerprint string) {
prefix := "argument_validation:" + name + ":" + shortSchemaFingerprint(fingerprint) + ":"
a.turn.loop.clearSchemaErrors(func(sig string) bool { return strings.HasPrefix(sig, prefix) })
}
func (a *Agent) clearSchemaErrorsAfterInspect(id string) {
if id == "" {
return
}
a.turn.loop.clearSchemaErrorsForCapability(id)
}
+16 -3
View File
@@ -54,9 +54,9 @@ const loopGuardBlockErrMsg = "blocked by loop guard"
// blocked outcomes. Any success resets both. When a guard fires — or when a
// call in the batch was already blocked by the per-call repeat-success guard —
// the final-readiness loop-guard pass is armed so the model may report the
// blocker (see loopGuardAllowsFinal). The hard maxSteps guard remains the
// ultimate backstop; this just keeps the loop from burning that whole budget
// bouncing off the same host refusals.
// blocker (see loopGuardAllowsFinal). A positive maxSteps value remains a hard
// backstop; with no explicit step or budget limit this guidance alone does not
// guarantee a finite run.
func (a *Agent) applyStormBreaker(calls []provider.ToolCall, outcomes []toolOutcome, receiptMark int) intervention {
allBlocked := len(outcomes) > 0
for _, outcome := range outcomes {
@@ -114,6 +114,19 @@ func (a *Agent) applyStormBreaker(calls []provider.ToolCall, outcomes []toolOutc
}
action := "failed"
advice := "Change approach: if an argument is being truncated, write less in one call and split the work into several smaller calls; otherwise fix the arguments, use a different tool, or explain the blocker in your final answer."
parameterErrors := true
hasSchemaError := false
for _, outcome := range outcomes {
validation := strings.HasPrefix(outcome.errMsg, "argument_validation:")
schema := validation && strings.HasSuffix(outcome.errMsg, ":schema")
parameterErrors = parameterErrors && validation && !schema
hasSchemaError = hasSchemaError || schema
}
if parameterErrors {
advice = "Stop repeating the same invalid input. Correct the parameters using the reported contract and retry; valid calls still undergo normal permission checks. If you cannot construct a valid call, report that tool argument generation failed and state what remains unfinished."
} else if hasSchemaError {
advice = "A host tool schema is invalid. Rewriting arguments cannot repair that configuration error; report it and the unfinished work. Handle any other failures according to their individual diagnostics."
}
if anyBlocked {
action = "been blocked or failed"
advice = blockedAdvice
-56
View File
@@ -11,8 +11,6 @@ import (
// share one lock instead of unsynchronized maps on turnRuntime.
type turnLoopState struct {
mu sync.Mutex
schemaErrors map[string]schemaErrorRecord
schemaCapabilities map[string]map[string]struct{}
dispatchClasses map[string]tool.CallClass
resultFingerprints map[string]string
acceptedDecisions map[string]acceptedDecision
@@ -34,60 +32,6 @@ func (s *turnLoopState) dispatchClass(id string) (tool.CallClass, bool) {
return class, ok
}
func (s *turnLoopState) incrementSchemaError(sig, capabilityID string) schemaErrorRecord {
s.mu.Lock()
defer s.mu.Unlock()
if s.schemaErrors == nil {
s.schemaErrors = map[string]schemaErrorRecord{}
}
record := s.schemaErrors[sig]
record.count++
s.schemaErrors[sig] = record
if capabilityID != "" {
if s.schemaCapabilities == nil {
s.schemaCapabilities = map[string]map[string]struct{}{}
}
if s.schemaCapabilities[capabilityID] == nil {
s.schemaCapabilities[capabilityID] = map[string]struct{}{}
}
s.schemaCapabilities[capabilityID][sig] = struct{}{}
}
return record
}
func (s *turnLoopState) markSchemaInspectAttached(sig string) {
s.mu.Lock()
defer s.mu.Unlock()
record := s.schemaErrors[sig]
record.inspectAttached = true
s.schemaErrors[sig] = record
}
func (s *turnLoopState) clearSchemaErrors(match func(string) bool) {
s.mu.Lock()
defer s.mu.Unlock()
for sig := range s.schemaErrors {
if match(sig) {
delete(s.schemaErrors, sig)
for id, signatures := range s.schemaCapabilities {
delete(signatures, sig)
if len(signatures) == 0 {
delete(s.schemaCapabilities, id)
}
}
}
}
}
func (s *turnLoopState) clearSchemaErrorsForCapability(id string) {
s.mu.Lock()
defer s.mu.Unlock()
for sig := range s.schemaCapabilities[id] {
delete(s.schemaErrors, sig)
}
delete(s.schemaCapabilities, id)
}
func (s *turnLoopState) rememberFingerprint(fp, callID string) (prev string, seen bool) {
s.mu.Lock()
defer s.mu.Unlock()
+6 -6
View File
@@ -645,7 +645,7 @@ func (*UseCapabilityTool) Schema() json.RawMessage {
func (t *UseCapabilityTool) ResolveCall(ctx context.Context, args json.RawMessage) (tool.ResolvedCall, error) {
p, action, id, err := parseUseCapabilityArgs(args)
if err != nil {
return tool.ResolvedCall{}, err
return tool.ResolvedCall{}, &capabilityInputError{err}
}
base := tool.ResolvedCall{
DisplayName: "use_capability",
@@ -658,11 +658,11 @@ func (t *UseCapabilityTool) ResolveCall(ctx context.Context, args json.RawMessag
return t.resolveDiscovery(ctx, p, action, id, base)
case "decline":
if id == "" {
return tool.ResolvedCall{}, fmt.Errorf("capability_id is required for action=decline")
return tool.ResolvedCall{}, capabilityInputErrorf("capability_id is required for action=decline")
}
reason := strings.TrimSpace(p.Reason)
if reason == "" {
return tool.ResolvedCall{}, fmt.Errorf("reason is required for action=decline")
return tool.ResolvedCall{}, capabilityInputErrorf("reason is required for action=decline")
}
// Decline must not skip require. The mutation itself is delayed until the
// agent has applied its post-resolution host boundary.
@@ -688,14 +688,14 @@ func (t *UseCapabilityTool) ResolveCall(ctx context.Context, args json.RawMessag
return base, nil
case "call":
if id == "" {
return tool.ResolvedCall{}, fmt.Errorf("capability_id is required for action=call")
return tool.ResolvedCall{}, capabilityInputErrorf("capability_id is required for action=call")
}
if id == sessionToolResultCapabilityID || id == sessionReadStrategyReceiptCapabilityID {
return t.resolveSessionCapability(id, p.Arguments, base)
}
return t.resolveCall(ctx, id, p.Arguments, base)
default:
return tool.ResolvedCall{}, fmt.Errorf("unknown action %q; use list, search, inspect, call, or decline", p.Action)
return tool.ResolvedCall{}, capabilityInputErrorf("unknown action %q; use list, search, inspect, call, or decline", p.Action)
}
}
@@ -1069,7 +1069,7 @@ func (o *onDemandMCPTool) executeWithImages(ctx context.Context, args json.RawMe
return "", nil, fmt.Errorf("MCP server %q changed the authorization or destructive classification for tool %q; the call was blocked before dispatch — retry so Reasonix can re-apply the current Planner MCP safety boundary", o.server, o.raw)
}
}
if blocked, msg := hostValidateBeforeDispatch(target, args); blocked {
if blocked, msg := hostValidateBeforeDispatch(target, args, "mcp-tool:"+o.server+"/"+o.raw); blocked {
return "", nil, fmt.Errorf("%s", msg)
}
if imageTool, ok := target.(tool.ImageTool); ok {
+2 -2
View File
@@ -34,7 +34,7 @@ func (t *UseCapabilityTool) resolveDiscovery(ctx context.Context, p useCapabilit
case "search":
query := strings.TrimSpace(p.Query)
if query == "" {
return tool.ResolvedCall{}, fmt.Errorf("query is required for action=search")
return tool.ResolvedCall{}, capabilityInputErrorf("query is required for action=search")
}
out, resultCount, err := t.searchCapabilities(query, p.Limit)
if err != nil {
@@ -49,7 +49,7 @@ func (t *UseCapabilityTool) resolveDiscovery(ctx context.Context, p useCapabilit
return base, nil
default:
if id == "" {
return tool.ResolvedCall{}, fmt.Errorf("capability_id is required for action=inspect")
return tool.ResolvedCall{}, capabilityInputErrorf("capability_id is required for action=inspect")
}
if id == sessionToolResultCapabilityID {
out, err := t.inspectSessionToolResult()
@@ -6,6 +6,15 @@ import (
"strings"
)
// capabilityInputError distinguishes repairable envelope errors from target
// availability and authorization failures without changing their error text.
type capabilityInputError struct{ error }
func (e *capabilityInputError) Unwrap() error { return e.error }
func capabilityInputErrorf(format string, args ...any) error {
return &capabilityInputError{fmt.Errorf(format, args...)}
}
type useCapabilityArgs struct {
Action string `json:"action"`
CapabilityID string `json:"capability_id"`
+2 -5
View File
@@ -54,7 +54,8 @@ type ArgumentAudit struct {
Validations, Fail, Skip, RemoteDispatch int
}
// LoopGuardAudit records deterministic stop/retry actions.
// LoopGuardAudit retains the metrics wire layout. RepeatFailures and
// BlockedCalls are retired schema-guard counters; new runs leave them zero.
type LoopGuardAudit struct {
RepeatFailures, RepeatClarifications, SoftBudgetNudges int
BlockedCalls int
@@ -168,14 +169,10 @@ func (a *Audit) RecordLoopGuard(kind string) {
a.mu.Lock()
defer a.mu.Unlock()
switch kind {
case "repeat_failure":
a.LoopGuard.RepeatFailures++
case "repeat_clarification":
a.LoopGuard.RepeatClarifications++
case "soft_budget":
a.LoopGuard.SoftBudgetNudges++
case "blocked":
a.LoopGuard.BlockedCalls++
}
}