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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ adheres to [Semantic Versioning](https://semver.org/).

## Unreleased

### yoagent

- **`ToolGate` judges large calls whole, and commands only whole.** Before, every string argument over 2,000 characters was cut to its head and tail, so a 100,000-character `write_file` was judged on 2,000 characters of content and a long `bash` script with `rm -rf` in its middle was judged without that line; a call still over 12,000 characters after that was denied. Now arguments are sent **whole when they fit** `ToolGate::with_max_argument_chars` (default 48,000 characters) and the decision backend's token limits; a call that does not fit has its longest strings cut only as far as needed, and the state lists what was cut under `tool_call.omitted`. **Full views** are never cut — judged whole or denied: `with_full_view(tool, json_pointer)`, by default the built-in `bash` tool's `/command` (`without_default_full_views` drops it). **Behaviour change:** a `bash` command too large to review in full (over about 48,000 characters) is now denied instead of judged from its head and tail; more calls are sent in full, so large calls cost more decision-model input tokens.

### yoagent-frontend (experimental, new, not published)

- **One frontend protocol** (`integrations/yoagent-frontend`): a `Session` runs one agent for any number of frontends — a pi-tui terminal UI (a rutis plugin) and browsers over WebSocket — with commands in (prompt, steer, follow-up, abort, reset, quit), and out every event (text deltas merged), a run lifecycle that is always delivered (`runStart` / `runEnd` with an `outcome`: completed, aborted, rejected or error — also when the agent's task or the session's driver fails), plugins' questions (`uiRequest`: confirm, select, input, notify; the first answer that fits wins, a safe default without a frontend or in time; `uiResolved` says why it closed), `notice`s, and `closed`, and **UI plugins**: a rutis plugin offers the browser an ES module (`renderTool` for a tool's results, `mountPanel` for a side panel). `PluginHost` replaces the rutis setup (runtimes, loader, explicit per-row runtime routing; `load` waits until plugins run — though a TypeScript plugin reads as running before its async start-up finishes, so wait for the handlers you need — and drops a batch that fails). The browser's WebSocket needs the token in the URL the server prints. Example `coding_agent` (terminal or `--web`), tried live in Chrome. yoagent's core and yoagent-rutis's API do not depend on it.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ Behind the `gasp` Cargo feature (dep: `yoagent-state`, the GASP reference runtim

Behind the `decision` Cargo feature (no deps; off by default; nothing is sent until a `DecisionModel` is constructed and used — an env key alone enables nothing, proven by `tests/decision_env_test.rs` against a wiremock `expect(0)`). Typed `Question`s (`Noul`/`Choice`/`Score`; "Noul" is SystemOne wire vocabulary) about a JSON/text `state` → `Evaluation` (private fields; getters `model()`, `usage()`, `cost_usd()`, `answers()` in request order; `new`/`with_answer`/`with_cost_usd` for backends); answers have private fields + getters (`NoulAnswer::p_true()`, `ChoiceAnswer` in option order, `ScoreAnswer`). `DecisionModel` (cheap clone) wraps a `DecisionBackend` trait (`capabilities()`, `evaluate(&Request)`): `SystemOneBackend` (HTTP `POST /v1/systemone`, or an exact URL via `with_endpoint_url`; its parser is public as `parse_systemone_response` for backends that move the request themselves; Cloudflare's `{"result": ...}` envelope unwrapped (a text `result` is parsed, a non-object one is `BadResponse`); a 2xx `"success": false` → `Invalid` for schema code 5006, else `Backend`; a 429 with Cloudflare code 3036 (daily allocation) → non-retryable `Http`, other 429/529 → `RateLimited` carrying the body; env keys trimmed, 401/403 name the variable; lenient parsing; 429/529 retried via `RetryConfig`, `retry-after` wins) and `MockBackend`. Presets `jev()` (`TYPESAFE_API_KEY`/`TYPESAFE_BASE_URL`, read at call time), `jev_opencode()`, `jev_opencode_free()` (`OPENCODE_API_KEY`), `clef(account_id)` / `clef_flash(account_id)` (Cloudflare Workers AI via `SystemOneBackend::workers_ai(account, "@cf/cloudflare/clef")`; token `CLOUDFLARE_API_TOKEN` then `CLOUDFLARE_AUTH_TOKEN`, read at call time), `local(url)` (sets `Capabilities::local` = self-hosted), `logprobs(url, model)` (`LogprobBackend`, see below), `gpt_6_luna()` / `from_openai_backend(backend, id)` (`OpenAiDecisionBackend` in `decision/openai.rs`, own `Slot::OpenAi` so `with_api_key`/`with_retry` apply: OpenAI's Decisions API `POST {base}/decisions`, base `https://api.openai.com/v1` (`/v1` included), `OPENAI_API_KEY` read at call time + trimmed, 401/403 name it; Noul→`predicate`, Choice→`choice` (string `value`s, criteria→`description`), Score→`score` (`levels[].label`, answer probabilities by level index `value`), id→`name`, JSON state/instructions → pretty JSON text, Noul criteria appended to instructions; answers matched by `name`, unnamed ones by position only when counts match; `refusal` → `DecisionError::Refused` naming the question (not retried, terminal in an `or` chain; gate/guard fail closed; its usage still recorded); no predicate confidence → computed; capabilities = `Capabilities::systemone()` (no published limits); `with_safety_identifier`; images unsupported; HTTP via `systemone::{post_json, with_retries}`; mock-only, never run live — `tests/decision_openai_test.rs`), `from_backend` / `from_arc`. Requests validated against `Capabilities` (2–255 options, 2–10 levels, 64k/32k token estimates); **answers validated centrally in `check_complete` for every backend** (kind, finite probs/confidence in [0,1], a probability for every Choice option / Score level, sums within `max(0.02, n × 0.005)` of 1 for n options/levels (TypeSafe rounds to 2 dp), choice ∈ options, score in range) → `BadResponse`; answers nobody asked for are dropped and the rest reordered to request order (so a non-batching merge cannot be overwritten). `DecisionError` is `Clone` without `PartialEq`; `Backend`/`Transport` are struct variants with an optional `Arc<dyn Error>` source. Missing confidence = `(n*p_max-1)/(n-1)`. Pricing (all unpriced until the process opts in — `prices::enable_bundled()` etc.): `jev()` uses `typesafe/<reported id>` from the process-wide table only when the base URL is TypeSafe's host; `clef*()` use `cloudflare/<model id>` (an `@cf/cloudflare/` prefix stripped) only while the host is `api.cloudflare.com`; `gpt_6_luna()` uses `openai-decisions/<reported id>` (its own key, not chat `openai/gpt-6-luna`; $0.10/M input, output free) only while the host is `api.openai.com`, an unlisted id warns once — only while pricing is enabled — via the per-key `decision::warn_once` (also used by the no-usage warning and the two `deprecated:` keys: gate as middleware, guard as filter; once per process); gateways and `from_backend` unpriced; `local()` $0; an unpriced handle keeps a backend-reported cost; a response without usage is unpriced under a non-zero price, while a free handle (`local()`, all-zero rates) stays $0.

Agent integration (`decision::wire` returns extensions, appended in `Agent::build_config` / `SubAgentTool`): `with_decision_model` → advisory only (crate-private `Advisor`, installed as the `AdvisorExtension` extension's `before_model` note: one batched request per user request, memoized; skill hint via Choice+Noul at need ≥0.3 / confidence ≥0.5, tool hint at ≥40 tools; 2 s; failures warn and add nothing; does nothing without skills and with <40 tools). `with_tool_gate(ToolGate)` → installed as an extension (`impl Extension for ToolGate`, `rechecks_modified_calls` = true; its `ToolMiddleware` impl is deprecated (warns once), `ToolGate::decide` drives it directly) appended after the agent's own extensions, so it runs last; Nouls destructive/requested, deny when destructive ≥0.5 and requested <0.7; state `{user_request, tool_call}` with long strings head+tail-truncated and >12k chars denied; every comparison denies on NaN/missing; threshold setters and check-id collisions panic; **fails closed**. Not a security boundary. Spend: every `evaluate_request` inside a loop records into the `tokio::task_local!` `LoopScope` (`agent_loop::record_decision`), opened in `agent_loop_with_stats` **before the input filters** (a rejected run's `AgentEnd` carries the spend) and in `agent_loop_continue_with_stats` → `SessionStats::decision` (`DecisionStats`, with `unpriced` counting evaluations of unknown cost), child runs folded in (not into `sub_agents`); counted in `total_cost_usd`; a non-batching request that fails or times out partway records the usage already billed (accumulated outside the timed-out future).
Agent integration (`decision::wire` returns extensions, appended in `Agent::build_config` / `SubAgentTool`): `with_decision_model` → advisory only (crate-private `Advisor`, installed as the `AdvisorExtension` extension's `before_model` note: one batched request per user request, memoized; skill hint via Choice+Noul at need ≥0.3 / confidence ≥0.5, tool hint at ≥40 tools; 2 s; failures warn and add nothing; does nothing without skills and with <40 tools). `with_tool_gate(ToolGate)` → installed as an extension (`impl Extension for ToolGate`, `rechecks_modified_calls` = true; its `ToolMiddleware` impl is deprecated (warns once), `ToolGate::decide` drives it directly) appended after the agent's own extensions, so it runs last; Nouls destructive/requested, deny when destructive ≥0.5 and requested <0.7; state `{user_request, tool_call}`: arguments whole when they fit `with_max_argument_chars` (48k chars) and the backend's token limits (`question::check_token_limits`, shared with `Request::validate`; the strictest across an `or` chain via crate-private `DecisionModel::chain_capabilities`, so no fallback skips the call unsent), else `fit_arguments` binary-searches one per-string cap (≥400) cutting the longest strings first and lists cuts in `tool_call.omitted` (JSON pointers); full views (`with_full_view(tool, pointer)`, default `bash` `/command`, `without_default_full_views`) are never cut — a full view that does not fit denies, as does a call that does not fit cut to 400; never judged in pieces; every comparison denies on NaN/missing; threshold setters and check-id collisions panic; **fails closed**. Not a security boundary. Spend: every `evaluate_request` inside a loop records into the `tokio::task_local!` `LoopScope` (`agent_loop::record_decision`), opened in `agent_loop_with_stats` **before the input filters** (a rejected run's `AgentEnd` carries the spend) and in `agent_loop_continue_with_stats` → `SessionStats::decision` (`DecisionStats`, with `unpriced` counting evaluations of unknown cost), child runs folded in (not into `sub_agents`); counted in `total_cost_usd`; a non-batching request that fails or times out partway records the usage already billed (accumulated outside the timed-out future).

Phase 3–4 (`decision/logprobs.rs`, `calibrate.rs`, `guard.rs`; fallback and the split path in `mod.rs`). **`LogprobBackend`** (`DecisionModel::logprobs(url, model)`, or `from_logprob_backend(backend, model)` to keep the logprobs slot — `with_api_key`/`with_retry`/loopback $0 — with custom settings; there is no `DecisionModel::with_temperature`): any OpenAI-compatible `/chat/completions` with logprobs; one request per question (`max_tokens:1`, `temperature:0`, `logprobs:true`, `top_logprobs:max(K=20, labels)`, `extra_body` deep-merged (nested objects key by key) and inserted under the core fields; `with_thinking_disabled` = `chat_template_kwargs.enable_thinking=false`, not default because OpenAI rejects unknown fields), single user message; labels A/B (Noul, A = yes), A.. (Choice, ≤26), 0–9 (Score); `KINDS` lists the three kinds explicitly and every match is exhaustive (a future kind → `Unsupported`). `label_distribution`: trim + upper-case, sum duplicates; no label or label mass < `min_label_mass` (0.5) → `BadResponse` (a `<think>` first token or a word answer is noise, not a confident answer — the gate/guard then fail closed); an absent label gets `min(smallest reported p, 1 − Σreported)` clamped to `[1e-9, 1]`; temperature on ln(mass), softmax, then an absent label is re-floored at 1e-9 after scaling (a tiny temperature would underflow it), so absence never gives 0/1. Reports `batching:false` + `Capabilities::max_concurrent_requests = 8` (new field, default 1): `DecisionModel::evaluate_singles` splits the request into boxed futures built in a loop (a closure trips the `Send` HRTB check), runs them via `FuturesUnordered` up to the limit, stops starting new ones after the first failure but awaits those in flight, adds each completed one to `billed` (so errors and timeouts record the spend of completed questions), returns the earliest failed question's error. `local` only for a loopback host (`is_loopback_url`: localhost/`*.localhost`/127/8/::1; `0.0.0.0` is *not*, which the unix-only remote-pricing wiremock test relies on); loopback → `Pricing::Fixed(0)`, else unpriced. HTTP shared with SystemOne via `systemone::{post_json, with_retries, status_error}` (429/529/transport retried; other 5xx is `Http`; a reqwest `is_builder()` error — malformed URL — is `Invalid`). **Fallback** `DecisionModel::or`: `fallbacks: Vec<DecisionModel>` (flattened; each member sends its own model id), `evaluate_chain` — structural validation against `Capabilities::unlimited()` first (→ `Invalid`, no member tried), then per member: capability-invalid → skipped unsent (`FallbackAttempt { was_sent: false }`, not recorded); any member error, `Invalid` included, falls through — except `Refused`, which is returned at once; one `rt::Instant` deadline from the head's `timeout` (members' `timeout` ignored) judged by the clock after each failure (a backend's own `Timeout` does not end the chain), per-attempt limit `min(remaining, attempt_timeout)`; deadline passed → `Timeout(head timeout)`; exhaustion → `DecisionError::AllFailed { attempts: Vec<FallbackAttempt> }` (getters `model`/`error`/`was_sent`; `is_retryable` if any member's error was). Each sent attempt is recorded (`record_attempt`); nothing sent → one failure. `model()`/`capabilities()`/builders are the primary's. **`calibrate` / `calibrate_with`** (`CalibrationOptions` private fields + getters): one request per example, `buffer_unordered(concurrency)`; top-label ECE (10 bins), Brier binary for Noul / multi-class otherwise, Noul F1 and target-precision thresholds over observed `p_true`, temperature by NLL grid `1.05^k, k∈[-33,33]` (ties → closest to 1) where only p>0 entries are rescaled and only the true outcome's rescaled p is floored; `accuracy`/`brier`/`ece` are `Option` (None when nothing evaluated); `models` counts per `Evaluation::model` + `mixed_models()`; `errors: Vec<CalibrationError>`. **`InputGuard`** (`Agent`/`SubAgentTool::with_input_guard` = `assert_has_checks()` (panics on no checks) + `with_extension` (`impl Extension for InputGuard`, `on_input`; its `AsyncInputFilter` impl is deprecated (warns once), `InputGuard::screen` drives it directly)): built-ins `injection` / `harmful` at 0.8, stored as a `defaults` flag + `custom` list + `thresholds` overrides, resolved lazily in `checks()` (a custom check with a built-in id replaces it in place and survives `without_default_checks`, whatever the call order); state `{input}` with the **whole** text (no truncation); over `max_input_chars` (32k) → not sent, failure policy; 3 s, fail-closed (`with_fail_open` opt-out); empty text → pass; no checks used directly → Reject; steering/follow-ups are not filtered (loop unchanged). Tests: `decision_logprobs_test` (wiremock, incl. split-request spend via an input guard), `decision_fallback_test`, `decision_calibrate_test`, `decision_guard_test` (MockBackend).

Expand Down
45 changes: 39 additions & 6 deletions docs/concepts/decision-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -525,17 +525,50 @@ backend.
3. Otherwise there is no request to judge against, and the call is denied
with a reason asking the user to restate it.

**Arguments.** Short values (paths, names, flags) are always sent whole. A
string over 2,000 characters keeps its head and its tail around an explicit
`[truncated N chars]` marker — so the `rm -rf` at the end of a long heredoc
is visible. If the arguments are still over 12,000 characters, the call is
denied without asking.
**Arguments.** A call's arguments are sent **whole when they fit**: within
`with_max_argument_chars` (default 48,000 characters, serialized) and the
decision backend's own token limits (with fallbacks, the strictest in the
chain, so no fallback skips the call). A full-file rewrite or a large patch is
judged on all of it.

A call that does not fit is shortened only as far as needed: its longest
strings are cut first, each to its head and tail around an explicit
`[truncated N chars]` marker, while short values (paths, names, flags) stay
whole. The state then says what was cut, so the model knows it is judging a
partial view:

```json
"tool_call": {
"tool": "write_file",
"arguments": { "path": "src/lib.rs", "content": "…[truncated 52310 chars]…" },
"omitted": [{ "argument": "/content", "chars": 52310 }]
}
```

Some arguments must never be judged in part: a command or a script can hide
its dangerous line anywhere. Those are **full views**, judged whole or not
at all — a call whose full-view argument does not fit is denied without
asking ("more than the gate can review in full"). The built-in `bash` tool's
`/command` is one by default; add your own with
`with_full_view(tool, json_pointer)` (a pointer to an array or object covers
everything under it), and drop the default with `without_default_full_views()`.

```rust,ignore
let gate = ToolGate::new(model)
.with_full_view("run_sql", "/query") // a query is judged whole or denied
.with_max_argument_chars(64_000); // more room before anything is cut
```

A call that does not fit even with every other long string cut to 400
characters is denied too. Calls are never judged in pieces: what one piece
does can depend on another (a `cd` in one, an `rm` in the next).

**What denies.** A call is denied — with a reason the model sees — when:

- it looks destructive and is not clearly requested (the thresholds above);
- a custom check (`with_check`) reaches its threshold;
- its arguments are over the size cap after shortening;
- a full-view argument (by default `bash`'s command) does not fit whole, or
the arguments do not fit even shortened;
- there is no user request to judge against;
- the decision model errors, times out (**5 s**), or answers with a missing,
malformed, non-finite or out-of-range value (**fails closed**).
Expand Down
Loading
Loading