Skip to content
Open
2 changes: 1 addition & 1 deletion agent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1336,7 +1336,7 @@
},
"tool_response_transform": {
"type": "array",
"description": "Hooks that fire between a tool's execution and the runtime's emission/record of the response, with the rewrite reaching event consumers, the persisted session, the post_tool_use hook input, and the next LLM call. A hook may rewrite the tool's textual output by setting hookSpecificOutput.updated_tool_response \u2014 the symmetric counterpart of pre_tool_use's updated_input, applied to tool RESULTS instead of tool ARGUMENTS. The redact_secrets builtin uses this event for the third leg of the redact_secrets feature; custom rewriters can also truncate excessive output, scrub PII, or normalise tool dialects. Tool-matched, like pre_tool_use / post_tool_use.",
"description": "Hooks that fire between a tool's execution and the runtime's emission/record of the response, with the rewrite reaching event consumers, the persisted session, the post_tool_use hook input, and the next LLM call. A hook may rewrite the tool's textual output by setting hookSpecificOutput.updated_tool_response \u2014 the symmetric counterpart of pre_tool_use's updated_input, applied to tool RESULTS instead of tool ARGUMENTS. The redact_secrets builtin uses this event for the third leg of the redact_secrets feature, and the opt-in elide_repeated_tool_results builtin uses it to replace a read-only tool's output with a short marker when it repeats what the model already saw; custom rewriters can also truncate excessive output, scrub PII, or normalise tool dialects. Tool-matched, like pre_tool_use / post_tool_use.",
"items": {
"$ref": "#/definitions/HookMatcherConfig"
}
Expand Down
9 changes: 5 additions & 4 deletions docs/configuration/hooks/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ Built-ins are typically zero-config and faster than equivalent shell hooks becau
| `snapshot` | `session_start`, `turn_start`, `turn_end`, `pre_tool_use`, `post_tool_use`, `session_end` | _none_ | Records filesystem snapshots in a shadow git repo under the Docker Agent data directory. No-op outside git repos; respects the source repo's ignore rules and skips newly-added files larger than 2 MiB. |
| `redact_secrets` | `pre_tool_use`, `before_llm_call`, `tool_response_transform` | _none_ | Scrubs detected secrets (API keys, tokens, private keys, …) out of tool call arguments, outgoing chat content, and tool output. The same builtin handles all three events and dispatches on the event name. Auto-registered on all three events by `redact_secrets: true` on the agent — see [`examples/redact_secrets_hooks.yaml`](https://github.com/docker/docker-agent/blob/main/examples/redact_secrets_hooks.yaml) for the manual wiring. |
| `limit_large_tool_results` | `tool_response_transform`, `session_end` | _none_ | **Always-on safety hook** — automatically injected by the runtime, no configuration required. When a tool result from the `filesystem`, `shell`, `mcp`, or `a2a` categories exceeds 2,000 lines or 50 KiB, the full payload is written to a per-session temp file and replaced in the conversation with a notice plus a bounded excerpt (2,000 lines, up to 50 KiB): the tail for most tools, but the head for the built-in filesystem `read_file`, whose notice suggests a follow-up call with `line`/`limit` to continue reading. The `session_end` leg deletes the temp directory. Internal toolsets (`memory`, `plan`, `tasks`, `think`, …) are not affected. |
| `elide_repeated_tool_results` | `tool_response_transform`, `session_end`, `after_compaction`, `session_start` | _none_ | Opt-in. When a read-only tool from the `filesystem`, `lsp`, `rag`, `memory` or `git` categories returns output byte-for-byte identical to what the model already saw for the same arguments in this session, the payload is replaced with a one-line marker. **Not a cache**: the tool always runs and its fresh output is what gets hashed, so a changed file can never be served stale — the saving is in tokens, not latency. Only acts on payloads smaller than the `limit_large_tool_results` threshold, since that always-on hook is injected first and its rewrite wins. The `session_end` / `after_compaction` / `session_start` legs drop per-session state; compaction in particular removes the payload a fingerprint stands for. See [`examples/elide_repeated_tool_results.yaml`](https://github.com/docker/docker-agent/blob/main/examples/elide_repeated_tool_results.yaml). |
| `safer_shell` | `pre_tool_use` | _none_ | **Deprecated compatibility shim.** The runtime now classifies every shell command natively (`safe` / `destructive` / `unknown`) and gates it through the session's [safety mode](../permissions/index.md#safety-modes), so this builtin no longer emits verdicts. Pinned entries keep working as pure labellers that attach classification metadata (`safety_label`, `blast_radius`, `category`, `reason`) to the call. Filters by tool name internally (no-op for non-shell calls). |
| `unload` | `on_agent_switch` | _none_ | POSTs `{"model": "<id>"}` to each of the previous agent's DMR model endpoints (`/_unload` by default, overridable per-model via `unload_api`) to free the GPU/RAM the just-departing model was holding. Pure HTTP — reads the model snapshot the runtime ships on `on_agent_switch` and depends on no provider-specific runtime state. Non-DMR providers (OpenAI, Anthropic, …) are silently skipped, so cross-provider chains are safe. Errors are logged and swallowed; agent switching never blocks on a slow or unreachable engine (each call has a 10 s timeout). See [`examples/unload_on_switch.yaml`](https://github.com/docker/docker-agent/blob/main/examples/unload_on_switch.yaml). |

Expand Down Expand Up @@ -310,10 +311,10 @@ In addition to the common fields, each event ships its own payload:

| Event | Extra fields |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `pre_tool_use` | `agent_name`, `tool_name`, `tool_use_id`, `tool_input` |
| `tool_response_transform` | `tool_name`, `tool_use_id`, `tool_input`, `tool_response` |
| `post_tool_use` | `agent_name`, `tool_name`, `tool_use_id`, `tool_input`, `tool_response`, `tool_error` |
| `permission_request` | `agent_name`, `tool_name`, `tool_use_id`, `tool_input` |
| `pre_tool_use` | `agent_name`, `tool_name`, `tool_use_id`, `tool_input`, `tool_category`, `tool_read_only` |
| `tool_response_transform` | `tool_name`, `tool_use_id`, `tool_input`, `tool_response`, `tool_category`, `tool_read_only` |
| `post_tool_use` | `agent_name`, `tool_name`, `tool_use_id`, `tool_input`, `tool_response`, `tool_error`, `tool_category`, `tool_read_only` |
| `permission_request` | `agent_name`, `tool_name`, `tool_use_id`, `tool_input`, `tool_category`, `tool_read_only` |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[medium] permission_request hook does not receive tool_category or tool_read_only despite docs claiming it does

The PR adds tool_category and tool_read_only to the permission_request row of the event-fields table, but runPermissionRequestHook in pkg/runtime/toolexec/dispatcher.go (line 843) still constructs hooks.Input by hand without those fields:

result := c.d.Hooks.Dispatch(ctx, c.a, hooks.EventPermissionRequest, &hooks.Input{
    SessionID:    c.sess.ID,
    ToolName:     toolName,
    ToolUseID:    c.tc.ID,
    ToolInput:    ParseToolInput(c.tc.Function.Arguments),
    SafetyPolicy: string(c.sess.GetSafetyPolicy()),
    // ToolCategory and ToolReadOnly are not set here
})

The new hooksInput() helper (added in this same PR) correctly populates both fields and is already used for pre_tool_use, pre_tool_use_pre_yolo, tool_response_transform, and post_tool_use — but runPermissionRequestHook was not updated to use it. Any permission_request hook that reads tool_category or tool_read_only will always receive zero values ("" and false), making the elide builtin's category guard silently ineffective on that event.

The fix is to replace the inline &hooks.Input{...} construction in runPermissionRequestHook with c.hooksInput() (optionally extended for any permission-request-specific fields that NewHooksInput does not cover).

Confidence Score
🟢 strong 100/100

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

please review this feedback @dwin-gharibi

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sure. Soon

| `session_start` | `source` — one of `startup`, `resume`, `clear`, `compact` |
| `user_prompt_submit` | `prompt` — the text the user just submitted |
| `user_steering_messages_submit` | `steering_messages` — the drained steering messages, in submission order |
Expand Down
50 changes: 50 additions & 0 deletions examples/elide_repeated_tool_results.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/usr/bin/env docker-agent run
#
# Opt in to eliding repeated read-only tool results.
#
# When a read-only tool returns output byte-for-byte identical to what the model
# already saw for the same arguments in this session, the payload is replaced
# with a one-line marker instead of being repeated. An agent that re-reads the
# same file across turns pays for it once.
#
# This is NOT a cache: the tool always runs and its fresh output is what gets
# hashed, so a changed file can never be served stale. The saving is in tokens,
# not latency.
#
# Whether the marker is good for model behaviour is unmeasured — some models may
# re-request the file anyway — which is why this is opt-in rather than on by
# default.

agents:
root:
model: claude
description: An agent that does not pay twice for the same file.
instruction: |
You are a helpful assistant working in a code repository.
Read files as you need them.
toolsets:
- type: filesystem
hooks:
tool_response_transform:
- matcher: "*"
hooks:
- type: builtin
command: elide_repeated_tool_results
# State is per session. Wire all three cleanup legs: session_end frees it,
# and compaction or a context reset removes the very messages a
# fingerprint stands for — keeping it would tell the model "nothing has
# changed" about bytes it can no longer see.
session_end:
- type: builtin
command: elide_repeated_tool_results
after_compaction:
- type: builtin
command: elide_repeated_tool_results
session_start:
- type: builtin
command: elide_repeated_tool_results

models:
claude:
provider: anthropic
model: claude-sonnet-4-5
6 changes: 6 additions & 0 deletions pkg/hooks/builtins/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@
// - limit_large_tool_results
// (tool_response_transform) — store oversized tool output in a temp file
// and replace it with a bounded tail plus notice
// - elide_repeated_tool_results
// (tool_response_transform, session_end) — replace a read-only tool's
// output with a short marker when it is byte-for-byte identical to what
// the model already saw for the same arguments in this session. The tool
// always runs, so this cannot serve stale data; it saves tokens, not I/O.
// - safer_shell (pre_tool_use) — deprecated labeller
// shim. The runtime classifies shell commands
// natively via pkg/safety; pinned entries only
Expand Down Expand Up @@ -112,6 +117,7 @@ func Register(r *hooks.Registry, opts ...Option) error {
r.RegisterBuiltin(MaxIterations, maxIterations),
r.RegisterBuiltin(RedactSecrets, redactSecrets),
r.RegisterBuiltin(LimitLargeToolResults, limitLargeToolResults),
r.RegisterBuiltin(ElideRepeatedToolResults, elideRepeatedToolResults),
r.RegisterBuiltin(SaferShell, saferShell),
r.RegisterBuiltin(HTTPPost, newHTTPPost(o.httpPostClient)),
r.RegisterBuiltin(Unload, unload),
Expand Down
Loading
Loading