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
2 changes: 1 addition & 1 deletion agent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1346,7 +1346,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`, `knowledge`, `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. Declines whatever `limit_large_tool_results` would rewrite — that always-on hook is injected first and its rewrite wins — which is its own categories over 50 KiB or 2000 lines, so large `lsp`, `memory` and `git` results stay elidable. Opting in on `tool_response_transform` is enough: the `session_end` / `after_compaction` / `session_start` legs, which drop per-session state, are wired from that entry automatically because compaction and context resets remove the very payload a fingerprint stands for. Writing them out explicitly is harmless — identical entries are deduplicated. 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` |
Comment thread
dwin-gharibi marked this conversation as resolved.
| `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
44 changes: 44 additions & 0 deletions examples/elide_repeated_tool_results.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/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:
# This entry is the whole opt-in. The builtin also needs its state
# dropped on session_end, after_compaction and session_start — compaction
# and a context reset remove the very messages a fingerprint stands for,
# and keeping it would tell the model "nothing has changed" about bytes it
# can no longer see. Those three legs are wired automatically from this
# one, so they are part of the feature rather than something to remember.
# Writing them out by hand is harmless: identical entries are deduplicated.
tool_response_transform:
- matcher: "*"
hooks:
- type: builtin
command: elide_repeated_tool_results

models:
claude:
provider: anthropic
model: claude-sonnet-4-5
44 changes: 44 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 Expand Up @@ -190,12 +196,50 @@ func ApplyAgentDefaults(cfg *hooks.Config, d AgentDefaults) *hooks.Config {
Hooks: []hooks.Hook{builtinHook(RedactSecrets)},
})
}
completeElideCleanupLegs(cfg)

if cfg.IsEmpty() {
return nil
}
return cfg
}

// completeElideCleanupLegs wires the state-dropping events of
// [ElideRepeatedToolResults] whenever a config opts into its transform leg.
//
// The builtin remembers, per session, what the model has already been shown.
// That memory is only true while the messages it stands for are still in
// context, so session_end, after_compaction and session_start have to drop it.
// Wiring them is not a preference: an operator who writes only the transform
// leg gets a builtin that, after the first compaction, tells the model "nothing
// has changed" about bytes that are no longer in its context — and keeps saying
// so until the underlying output changes.
//
// This does not opt anybody in. It completes a feature the config already asked
// for, the same way redact_secrets wires its three legs together, and the
// dedup in Executor.hooksFor makes it idempotent against entries the operator
// wrote by hand.
func completeElideCleanupLegs(cfg *hooks.Config) {
if !declaresBuiltin(cfg.ToolResponseTransform, ElideRepeatedToolResults) {
return
}
cfg.SessionEnd = append(cfg.SessionEnd, builtinHook(ElideRepeatedToolResults))
cfg.AfterCompaction = append(cfg.AfterCompaction, builtinHook(ElideRepeatedToolResults))
cfg.SessionStart = append(cfg.SessionStart, builtinHook(ElideRepeatedToolResults))
}

// declaresBuiltin reports whether any matcher in entries runs the named builtin.
func declaresBuiltin(entries []hooks.MatcherConfig, name string) bool {
for _, entry := range entries {
for _, h := range entry.Hooks {
if h.Type == hooks.HookTypeBuiltin && h.Command == name {
return true
}
}
}
return false
}

// builtinHook returns a hook entry that dispatches to the named builtin.
func builtinHook(name string, args ...string) hooks.Hook {
return hooks.Hook{Type: hooks.HookTypeBuiltin, Command: name, Args: args}
Expand Down
Loading
Loading