Skip to content

feat(decision): ToolGate judges large calls whole, and commands only whole - #309

Open
yuanhao wants to merge 2 commits into
mainfrom
feat/gate-full-view
Open

yuanhao wants to merge 2 commits into
mainfrom
feat/gate-full-view

Conversation

@yuanhao

@yuanhao yuanhao commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator

Why

A question about ToolGate asked whether big writes are handed back to the user because of the 12k argument cap. They weren't — but the real edge was worse. A mock-only probe of main:

Call Gate saw Outcome
write_file, 100k content 2,143 chars judged on head + tail
bash, 100k command, rm -rf ~ in the middle 2,514 chars, without the rm judged without the dangerous line
edit_file, 50k + 50k 4,178 chars judged on head + tail
a multi-edit tool, 6 × 2 × 3k nothing denied (24k after shortening)

What changes

  • Whole when it fits. Arguments are sent whole within ToolGate::with_max_argument_chars (default 48,000 characters, serialized) and the decision backend's token limits. question::check_token_limits is extracted from Request::validate, so the gate fits to exactly what validation would accept.
  • Cut only what's needed, and say so. A call that does not fit gets one per-string cap, binary-searched (at least 400 characters), so the longest strings take the cut and short ones (paths, flags) stay whole. The state lists every cut, so the decision model knows it judges a partial view:
    "tool_call": { "tool": "write_file", "arguments": {…}, "omitted": [{"argument": "/content", "chars": 52310}] }
  • Full views: commands are judged whole or not at all. with_full_view(tool, json_pointer) (a pointer to an array/object covers everything under it, RFC 6901 escaping). The built-in bash tool's /command is a full view by default (without_default_full_views drops it). A full view that doesn't fit denies without asking: "more than the gate can review in full".
  • Still denied: a call that doesn't fit even with every other long string cut to 400. Never chunked: judging pieces separately misses how they connect (a cd in one, an rm in the next).
  • The "no user request" check now runs before the size check (the state needs it to measure the fit).

Behaviour change (CHANGELOG): a bash command over roughly 48k characters is now denied instead of judged from its head and tail; more calls are sent whole, so large calls cost more decision-model input tokens and take longer (the 5 s timeout still applies).

Tests

  • tests/decision_agent_test.rs: the three old long-argument tests are replaced by eight:
    • a 30k write sent whole;
    • a 100k write cut to the budget, with omitted and the full content still executed;
    • a backend with a 4,000-token limit (cut to fit, not refused);
    • bash with rm -rf ~ in the middle: sent whole when it fits, denied unsent when it doesn't, shortened with the default dropped;
    • full views on custom tools and subtrees;
    • pointer validation;
    • the too-large denial;
    • a configurable budget.
  • src/decision/gate.rs unit tests: whole-segment matching (/command ≠ /commands), ~0/~1 escaping, longest-first cutting, and blaming the full view.
  • Locally: cargo test --all-features (no failures), clippy --all-targets --all-features -D warnings, rustdoc -Dwarnings, wasm32 clippy with decision, cargo fmt --check.

Docs: docs/concepts/decision-models.md (the Arguments section, What denies), CLAUDE.md, CHANGELOG.

🤖 Generated with Claude Code

https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG

yuanhao and others added 2 commits October 13, 2026 00:40
…whole

Before, every string argument over 2,000 characters was cut to its head
and tail: a 100k write_file was judged on 2k of content, and a long bash
script with `rm -rf` in its middle was judged without that line. A call
still over 12k after that was denied.

Now arguments are sent whole when they fit with_max_argument_chars
(default 48,000) and the backend's token limits (check_token_limits,
shared with Request::validate). A call that does not fit has its longest
strings cut only as far as needed (one per-string cap, binary-searched,
at least 400), and the state lists the cuts under tool_call.omitted.
Full views (with_full_view(tool, json_pointer); default bash /command;
without_default_full_views) are never cut: one that does not fit denies.
Calls are never judged in pieces.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG
…back chain

An unlimited primary that failed left a fallback with smaller token
limits to skip the call unsent; the gate then denied it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant