Skip to content

feat(gateway): command hooks for deterministic chat commands - #199

Open
bkuri wants to merge 3 commits into
owainlewis:mainfrom
bkuri:feat/command-hooks
Open

feat(gateway): command hooks for deterministic chat commands#199
bkuri wants to merge 3 commits into
owainlewis:mainfrom
bkuri:feat/command-hooks

Conversation

@bkuri

@bkuri bkuri commented Sep 8, 2026

Copy link
Copy Markdown

Problem

Every chat message today, including slash commands, costs a full backend turn. Gateway built-ins (/clear, /help, /stop) short-circuit, but anything user-defined has to route through the LLM even when the desired behavior is fully deterministic; for example a command that runs a script and relays its output.

Change

Adds a [command_hooks] config table mapping chat slash commands to shell commands, handled in the existing gateway command path before backend dispatch:

  • Reply is the hook's trimmed stdout, relayed verbatim; no agent turn.
  • A mapped command owns all its forms: message args are appended to the command line as trailing positional parameters (/report agentsreport.sh agents, argv-safe, never re-parsed). Command-shaped input stays deterministic by default; it never becomes prompt content.
  • Failure, empty output, and timeout (>15s) reply with a short deterministic error (a broken hook never silently becomes an LLM turn).
  • Unknown slash commands still reach the backend as before, so backend-side command fallbacks keep working.
  • Hooks run through /bin/sh (POSIX) with a 15s timeout and a 64 KiB stdout cap; a typing indicator (channel permitting, same refresh loop as agent runs) stays on for the whole hook execution.
  • Hooks receive message context as env: PUSH_THREAD, PUSH_BACKEND, PUSH_ROW_ID, PUSH_SESSION_ID (set only when the thread already has a backend session, via a read-only lookup that never creates rows). Hooks run with the gateway's environment; the docs carry an explicit warning.

Queueing semantics

Hooks run in the thread's existing job queue like any other message: if the backend is mid-reply, a hook command waits for that turn to finish and runs afterward; replies stay in order and never interleave, but the hook does not preempt a running turn. /stop is the exception: it acts on the in-flight request immediately, as before. (If preemption for hook commands is ever wanted, that's a separate, larger feature.)

Verification

  • cargo fmt --all --check, cargo clippy --locked --all-targets -- -D warnings, cargo build --locked, cargo test --locked all pass (448 tests).
  • New tests: hook stdout relay with and without message args (arg passthrough + hook env asserted), no backend call involved; unknown-command fallthrough to the backend. Docs build (mkdocs build --strict).

Risk

Low: default config (command_hooks empty) is behavior-identical to today. The command() signature gains async (call site is already async context). Docs updated on the canonical page (docs/reference/cli.md). The hardened hook runner in #197 (process groups) is a better execution engine; this PR ships the simpler runner deliberately and the two can be unified when #197 lands.

Review hardening (post automated review)

Addressed all findings from the automated review pass:

  • stdout/stderr capped incrementally at 64 KiB (bounded memory even mid-stream; cap-exceeded is a deterministic error reply)
  • hooks run in their own process group; timeout/cap paths signal the whole group (no orphaned descendants)
  • PUSH_SESSION_ID is backend-scoped; after a route change the previous backend's session cannot leak into hook env
  • command word splits on general whitespace (/report\nagents routes like /report agents)
  • built-ins restored to exact matching (/clear typo reaches the backend unchanged)
  • hook names validated at config load (lowercase-normalized, ASCII word chars, no built-in shadowing)

Bernardo Kuri added 2 commits September 9, 2026 16:19
[command_hooks] maps chat slash commands to shell commands whose stdout
is relayed verbatim before backend dispatch - no agent turn, no tokens.
A mapped command owns all its forms: message args are appended to the
command line as trailing argv parameters, so command-shaped input never
becomes prompt content.

Hooks run through /bin/sh with a 15s timeout and a 64 KiB stdout cap;
failures, empty output, and timeouts reply with a short deterministic
error instead of falling back to the agent. A typing indicator (same
refresh loop as agent runs) stays on for the whole hook execution.

Hooks receive message context as env - PUSH_THREAD, PUSH_BACKEND,
PUSH_ROW_ID, PUSH_SESSION_ID (read-only lookup; never creates rows).
/help lists configured hooks under Custom commands. Hooks run in the
thread queue like any message: no preemption of a running turn, replies
stay in order; /stop remains the immediate exception.
@bkuri
bkuri force-pushed the feat/command-hooks branch from d437d2d to bed5732 Compare September 9, 2026 22:19
@bkuri
bkuri marked this pull request as ready for review September 9, 2026 22:26
@greptile-apps

greptile-apps Bot commented Sep 9, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds deterministic gateway command hooks that execute configured shell commands without invoking an agent.

  • Validates and normalizes hook names while rejecting built-in shadowing and normalized collisions.
  • Executes hooks in the existing per-thread queue with bounded output, timeout handling, process-group cleanup, typing refreshes, and contextual environment variables.
  • Preserves unknown-command backend fallback and exact matching for built-in commands.
  • Adds coverage for dispatch, argument passing, multiline commands, output limits, backend-scoped sessions, and configuration collisions.
  • Documents hook behavior, queueing, limits, and environment-security considerations.

Confidence Score: 5/5

The PR appears safe to merge; the latest change correctly rejects normalized command-name collisions and no outstanding findings remain.

The latest validation checks for an existing normalized key before insertion, eliminating nondeterministic command selection. The current code also fully addresses every earlier hook-routing, resource-bounding, process-cleanup, metadata-scoping, and configuration-validation finding.

Important Files Changed

Filename Overview
src/config.rs Adds command-hook configuration, normalization, validation, collision rejection, and focused tests.
src/gateway/worker.rs Implements slash-command routing and bounded, timeout-controlled hook execution with process-group cleanup.
src/gateway/tests.rs Covers successful hooks, argument handling, multiline routing, fallback behavior, output caps, and backend-scoped session metadata.
src/store.rs Adds a read-only, backend-scoped session lookup for hook context.
docs/reference/cli.md Documents command-hook configuration, execution semantics, limits, queueing, and security implications.

Sequence Diagram

sequenceDiagram
    participant User
    participant Gateway
    participant Queue as Thread Queue
    participant Hook as /bin/sh Hook
    participant Backend

    User->>Gateway: Slash-command message
    Gateway->>Queue: Enqueue message
    Queue->>Gateway: Process in order
    alt Exact built-in command
        Gateway-->>User: Built-in response
    else Configured command hook
        Gateway->>Hook: "Execute with argv and PUSH_* environment"
        Hook-->>Gateway: Bounded stdout / status
        Gateway-->>User: Output or deterministic error
    else Unknown slash command
        Gateway->>Backend: Dispatch unchanged message
        Backend-->>Gateway: Agent response
        Gateway-->>User: Agent response
    end
Loading

Reviews (3): Last reviewed commit: "fix(gateway): address hook runner review..." | Re-trigger Greptile

Comment thread src/gateway/worker.rs Outdated
Comment thread src/gateway/worker.rs Outdated
Comment thread src/gateway/worker.rs Outdated
Comment thread src/gateway/worker.rs Outdated
Comment thread src/gateway/worker.rs Outdated
Comment thread src/gateway/worker.rs Outdated
Comment thread src/gateway/worker.rs Outdated
// command line as trailing positional parameters (argv-safe, no
// re-parsing). Command-shaped input stays deterministic by
// default — it never becomes prompt content.
let hook = ctx.cfg.command_hooks.get(word)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Hook names remain unvalidated

Hook names are loaded without validation or normalization, but dispatch lowercases the incoming command before performing an exact map lookup. An entry such as Report = "..." is therefore shown by /help, while /Report misses the hook and falls through to the backend despite the documented case-insensitive behavior. Built-in and whitespace-containing keys can likewise be accepted but remain unreachable, creating misleading configuration and help output. Normalize or reject unusable names during configuration validation.

Knowledge Base Used: Configuration and environment

@bkuri
bkuri marked this pull request as draft September 9, 2026 23:09
@bkuri
bkuri marked this pull request as ready for review September 10, 2026 15:58
Comment thread src/config.rs
- Cap hook stdout/stderr incrementally at 64 KiB (previous cap sliced
  the buffered String, could panic on a multibyte boundary, and buffered
  the full output before truncating). Cap-exceeded is a deterministic
  error reply.
- Run hooks in their own process group and signal the whole group on
  timeout and cap paths so backgrounded descendants do not outlive the
  hook.
- Scope peek_session_id to the dispatching backend: after a route
  change, the stored session belongs to the previous backend and must
  not leak into hook env.
- Split the command word on general whitespace so /report\nagents
  routes like /report agents.
- Built-ins require exact matching again: /clear typo reaches the
  backend unchanged, as before hooks.
- Validate command_hooks names at load (lowercase-normalized, ASCII
  word characters, no built-in shadowing); unreachable or ambiguous
  names now fail config load instead of silently never matching.
@bkuri
bkuri force-pushed the feat/command-hooks branch from dc01b3a to 9e12c64 Compare September 10, 2026 16:07
@greptile-apps

greptile-apps Bot commented Sep 10, 2026

Copy link
Copy Markdown

Want your agent to iterate on Greptile's feedback? Try greploops.

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