Skip to content

v0.6.0 — the gate holds under load

Latest

Choose a tag to compare

@renezander030 renezander030 released this 05 Sep 06:51
· 4 commits to master since this release
19f2d9a

The tool-call gate now holds for real harnesses, and the operator's taps no longer race each other.

Everything is additive for a config without tool_gate:. Three defaults change behaviour where the tool gate or OpenRouter is already in use — see Compatibility.

Decisions that survive the harness

A decision that needs a human takes as long as the human takes, and an HTTP client that gave up after 30 seconds used to lose it. POST /gate/tool-call now takes "mode": "async" (answers 202 with an approval_id at once) or "wait": "30s" (holds that long, then 202 with the id while the approval keeps running). The harness collects the decision from GET /gate/tool-call/<id>, optionally long-polling with ?wait=30s. An unknown id is 404 deny — the fail-closed answer for "the gate restarted, ask again". The plain sync call is unchanged and now also carries the approval_id.

A tool call waiting on a human is written to pending_approvals before the prompt goes out, exactly like a pipeline gate: it appears in /pending, and a process that dies mid-wait reconciles it as interrupted at next boot instead of the gate vanishing.

Rules that read the arguments

tool_gate:
  tools:
    - name: send_email
      risk: high
      require_approval: true
      args:
        to: {glob: "*@example.com"}   # inside the rule: ask as usual
      on_mismatch: deny               # outside it: refuse without asking

args: constrains top-level arguments with equals, one_of, glob, regex, min/max and optional. A match keeps the rule's base behaviour. A mismatch either asks a human (on_mismatch: approve, the default) or refuses (deny) — it can only tighten a rule, never widen it. draftcat validate refuses a regex that does not compile, a malformed glob, an empty constraint, min > max, and remember_approval on a high-risk tool. Every decision the gate makes on its own now names its rule in the response.

One prompt per call, not per retry

Inside repeat_window (default 10m, 0 disables) an identical call — same agent, tool and argument hash — gets the gate's remembered answer. A call the operator or a rule denied is denied again without a new prompt. An identical call still being decided joins the open prompt and receives the same decision. max_repeats caps how many prompts one identical call may raise. Approvals are not reused unless a rule opts in with remember_approval: true, because the next send has the same side effect as the last.

Nothing refused silently

A refusal the gate makes on its own — an unlisted tool, an argument outside a deny rule, the repeat guard — is reported to the operator channel, one notice per agent, tool and reason inside notify_window (default 10m). notify_denials: false turns it off.

One Telegram update pump

Every approval waiter and the command loop each polled getUpdates on their own ticker against one unsynchronised offset. With scheduled, webhook-triggered and tool-gate approvals all running concurrently, two pollers were routinely live at once, and whichever fetched the operator's tap kept it — a tap that landed in the command loop was dropped and the gate timed out. Now one goroutine polls and routes: a callback to the waiter that owns the message, adjustment text to the waiter that asked for it (slash commands always reach the command loop), everything else to the command loop. Same buttons, same security checks, same messages. Also fixed: an Adjust tap dereferenced a nil message, and the per-user rate limiter's map was written from concurrent gates without a lock.

Budgets on the real charge, calls that wait their turn

On OpenRouter every call asks for usage accounting and the cost caps are enforced on the charge the provider reports, reasoning tokens and cached-token discounts included; the configured per-1k rates remain the fallback and the only source for other OpenAI-compatible endpoints. provider.usage_accounting: false opts out. Transient failures (429, 408, 5xx, network) are retried with jittered backoff that honours Retry-After; other 4xx fail immediately as before. provider.max_retries sets the budget.

What is waiting on you

/pending on the channel and draftcat pending [--json] on the host list every gate waiting on a human — pipeline steps and tool calls — with age, time left and quorum. /status shows tokens and spend against every configured cap and the count of open gates.

Compatibility

Where the tool gate is already in use: the repeat guard is on (repeat_window: 0 restores re-prompting) and denial notices are on. Where the provider is OpenRouter: cost caps now count the provider's reported USD charge rather than the configured rates. The state schema is unchanged.

Upgrading

docker pull ghcr.io/renezander030/draftcat:0.6.0, or rebuild from source. Existing configs need no changes. Full contract for the gate: docs/tool-gate.md.