0007 — Tool calls & execution: the agent loop, five tools, safety policy, confirmations

Context

Phases 1–2 stream prose into a terminal. This slice makes DashFloat an agent: the model's tool calls parse out of the stream, execute against the local machine under a path policy, mutations wait for a human keypress, and results ride back into the conversation so the model sees outcomes. The fourth ADR-0001 process — the Subprocess Executor — is built here and wired into the second, the Agent Manager, which until now existed only as a roadmap line. Non-negotiables inherited from the earlier ADRs: the UI process performs zero I/O and owns the only timer (the ADR-0006 tick); everything crossing a process boundary is a plain message; a tool failure is a result, never a crash.

Decision

1. Tool protocol (OpenAI-compatible)

Request side. Every completion request carries the fixed five-tool catalogue (ADR 0001's core toolset) as the tools array, plain JSON-schema parameters, no tool_choice (provider default = auto), no parallel_tool_calls pin:

Tool Parameters
read_file path (string, required), offset (int ≥ 1, default 1), limit (int 1–10 000, default 2 000) — 1-based line window
write_file path (required), content (required) — creates parent directories
edit_file path, search_block, replace_block (all required) — exact-string match, must match exactly once
execute_command command (required), cwd (default ".") — a shell command line, run with sh -c semantics
list_directory path (default ".")

Providers without tool support fail through ADR-0005's HTTP error taxonomy — a chat entry, not a crash.

Response side — incremental tool_calls delta assembly. OpenAI-compatible streams deliver tool calls as choices[0].delta.tool_calls fragments: the first fragment for an index carries id, type, function.name and the opening of function.arguments; subsequent fragments carry only index and an arguments string fragment to concatenate. Multiple calls interleave by index. DashFloat.LLM.ToolCalls is a pure accumulator:

new() :: t()
feed(t(), [delta_map()]) :: t()          # merge by index: id/name set once, arguments appended
finish(t()) :: [%{id: id, name: name, args: map() | nil, error: nil | String.t()}]

Rules, each driven by a test:

Round trip. When an episode yields calls, the agent appends the assistant message (canonical form: arguments re-encoded as a JSON string) and, after execution, one role: "tool" message per call, in call order, tool_call_id echoing the provider id, content a JSON-encoded result object. Results are structured, bounded, and honest:

{"ok":true,"path":"lib/foo.ex","content":"...","lines":120,"truncated":false}
{"ok":false,"error":"path escapes the working directory: ../../etc/passwd"}
{"ok":false,"rejected":true}                    # user pressed Esc on the confirmation
{"ok":false,"cancelled":true}                   # user interrupted a running command
{"ok":false,"error":"timed out after 30s","output":"...partial..."}

Every terminal path of the loop appends a tool message for every announced call — a conversation with dangling tool_calls and no results is an API error waiting to happen; cancel and interrupt therefore synthesize rejected/cancelled results for all unresolved calls.

2. Execution architecture

The ADR-0001 four-process shape, filled in:

UI process (ExRatatui)      DashFloat.Agent (GenServer)         Tasks under DashFloat.TaskSupervisor
  submit ──{:prompt,ui,text}─▶ append to conversation ──start──▶ DashFloat.LLM.ChatStream (SSE + parse)
  ◀──display contract───────  forwards tokens, phases,            │ {:token}/{:usage}/
  {:confirm_decision,ref,──▶  orchestrates the tool loop,         │ {:stream_failed}/{:stream_done}
   :approved|:rejected}       builds previews, dispatches         ▼
  {:cancel} ────────────────▶ execution                        DashFloat.Tools.Runner (one Task per call)
                                 │                                 file tools: File module
                                 │                                 execute_command: Port (Exec)
                                 └─ {:tool_result, id, result} ◀───

Exec reality, verified against the runtime (not the docs): Port.open({:spawn, command}, …) prepends exec — a single command, no shell; ;-chained tails are silently dropped and builtins rejected. Exec therefore wraps every command in a POSIX-quoted /bin/sh -c so the model's command lines mean what they say. And Port.close/1 closes the port but never kills the port program: a busy-looping or backgrounded child would outlive it. Every exec completion path (exit, timeout, cancel-kill) therefore also SIGKILLs the command's whole process group (setsid-spawned under its own group) — no leaked grandchildren. What a setsid-escaping child does beyond that is out of scope: a process sandbox is a recorded future hardening option, not this slice. One accepted consequence: a grandchild that inherits stdio and outlives the command holds the pipe open, so exit_status may wait until the timeout — bounded, visible, honest.

Write hardening: write_file/edit_file write through an exclusive temp file (O_EXCL, cryptographically random name, created in the target's own directory) and rename/2 into place — an attacker cannot pre-place a symlink at the temp name between resolve and write, and an existing file's mode is preserved (no widening). The residual parent-directory race (swapping a directory component between realpath and open) remains accepted per §3's standing judgment.

Boundaries: exec output is head-capped at 32 KiB with a trailing marker naming the total byte count; read_file caps at 2 000 lines and 64 KiB (marker reports both totals) — and reads are bounded, not just output: files above 256 KiB stream only the requested line window (the exact line total is traded away; truncated is learned from a one-line probe past the window); edit_file refuses files over 8 MiB by stat, before reading; list_directory stats at most 501 entries (the alphabetical head of the sorted names — a listing beyond the cap hides later-named entries, disclosed by truncated; on this runtime File.ls silently skips non-UTF-8 names); edit previews are window-capped at 50 lines with a marker, write previews likewise; the episode's own accumulations (content plus tool-call ids, names, and argument fragments — ids/names counted per fragment as conservative headroom) abort the stream at 4 MiB and 64 distinct tool calls — a trickling endpoint that never trips the idle timeout or the frame cap still cannot grow memory without bound (the call cap counts distinct stream indices, so one call split into a thousand fragments is one call). Exec timeout default 30 s (agent opt :exec_timeout, injectable for tests). Nothing buffers without a bound.

3. Safety policy

Path policy. All five tools take paths relative to the agent's working directory (the process cwd at startup; :cwd opt for tests). DashFloat.Tools.PathPolicy.resolve/2 runs before any file operation:

  1. Lexical expand (Path.expand(path, root)) — collapses .., handles absolute paths.
  2. Byte-prefix scope check against the root with a / boundary (/home/u/root must not admit /home/u/root-evil). An absolute path inside the root is fine; anything lexically outside is rejected.
  3. Symlink check: realpath the deepest existing ancestor and require it to stay inside realpath(root); the not-yet-existing lexical remainder cannot be a symlink. A symlink inside the tree pointing outside is therefore rejected — as is a .. chain, an absolute outside path, or an escape via a symlinked intermediate directory. (:file.realpath/1 does not exist on this OTP; the resolution is implemented once, in PathPolicy, on top of :file.read_link_info/1.) Mutations additionally write through an exclusive temp + atomic rename (§2), closing the check-then-open window at the file itself.

Residual risk, accepted and recorded: check-then-open is a TOCTOU window against an attacker who can mutate the tree between resolve and open — an attacker who already has local write access to the working directory, at which point DashFloat's boundary is not the perimeter that matters. Byte-exact comparison (no case folding, no Unicode normalization) is sound on case-sensitive filesystems and errs strict on case-insensitive ones — a lexically-inside string can only resolve outside via a symlink, which step 3 catches. execute_command is scoped only in its cwd argument; the command itself is arbitrary — that is exactly why it sits behind a confirmation.

Rejections are results to the model and rows in the chat log — the failure is visible in both places, never silent, never a crash.

edit_file's search-block protocol. DashFloat.Tools.Edit.locate/2 counts exact byte matches of search_block in the file: zero matches → error result search block not found; two or more → error result naming the count. Only a unique match mutates. Empty search_block is an error by construction (it matches everywhere); search_block == replace_block is an error (a no-op write is a model bug worth reporting back). Preview and runner share locate/2, so what the user approved is byte-identical to what lands.

Arg validation. DashFloat.Tools.validate/2 checks name against the catalogue and every parameter's presence/type/range before execution; unknown tool or bad args → error result, no execution, no confirmation.

4. Confirmation semantics

5. UI contract

The ADR-0002/0006 vocabulary stands; the additions:

Message (agent → UI) Effect
{:tool_call, %{id, name, args}} a dim tool row: accent ⚙ glyph, tool name, args summary (read_file lib/foo.ex), built by the UI from args
{:tool_result, %{id, name, ok, status, output}} a dim outcome row under the call (→ ok, → rejected, → error: …), output an engine-bounded excerpt (≤ 30 lines) rendered verbatim, dim
{:confirm, ref, %{id, name, args, lines}} appends the preview as a styled tool entry (+/- prefixed lines per the diff vocabulary), stores confirm, phase → :confirming

UI → agent: {:prompt, ui_pid, text} on submit, {:confirm_decision, ref, decision} on the keypress, {:cancel} on Esc — plain sends, no casts (fake agents in tests are plain processes). The state gains agent (pid or name, mount opt, default DashFloat.Agent), a monitor on it (agent_down degrades exactly like the old task_down: system entry + idle, input keeps working), and confirm. The old task_pid/task_ref pair goes — the agent owns the stream task now. Phase :confirming joins the vocabulary: status bar Confirm (tool yellow), spinner keeps stepping (it is an active phase — the tick discipline is untouched). Tool output is plain dim text, not markdown — it is verbatim machine/file content, and markdown-styling it would misrepresent it (headings inside a source file); assistant prose stays the only markdown-rendered kind. No new Theme roles: the ⚙ glyph uses :accent, diff + prefixes :inline_code, - prefixes :dim — the ADR-0006 warm-dim palette, no rainbow.

ADR-0006 amendment (usage across episodes). A turn is now multi-episode (stream → tools → stream), and each episode's usage frame counts the whole conversation again — banking every episode would inflate the context display. The agent forwards only the final episode's usage frame to the UI; intermediate ones are dropped engine-side. The in-contract ordering rule (usage before the phase change) is unchanged.

6. Conversation and lifecycle

The agent's message list starts [user] and grows: assistant (content and/or tool_calls), tool results, next user turn. History is whole-conversation per request (truncation is Phase 4's problem, per ADR-0001). Empty episodes (no content, no calls) degrade to a [model returned nothing] system entry. A crashed ChatStream (abnormal DOWN) mirrors ADR-0002's producer rules: finalize partial content, system entry, idle — the agent itself never crashes the UI. The Application starts DashFloat.TaskSupervisor, the named DashFloat.Agent (with the loaded provider), then the TUI (which receives the agent name as a mount opt); in Mix.env() == :test only the task supervisor starts — tests own their agents.

7. Testing

Interface freeze

Alternatives considered

Consequences