GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — feat(coderd/x/chatd): resolve execute tool calls on user interrupt by mafredri · Pull Request #29976 · coder/coder

Conversation

@mafredri

@mafredri mafredri commented Sep 26, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Member

A user interrupt stopped waiting for a foreground execute call but left the process running on the workspace agent, and the model was told the call "was interrupted before it produced a result". This PR makes interrupt handling cancel unresolved execute calls on the agent and commit what actually happened (US1, US2, US5, US6, US7, US8, US10).

Before the FinishInterruption transaction, the interrupt task connects to the workspace agent the way generation does and sends one CancelToolCall per unresolved execute call, in parallel. Each cancel waits at most #29965's agent answer timeout (1 minute), after a dial bounded by chatd's 30-second dial timeout, so an unreachable or silent agent yields "unknown". The request asks the agent to stop a foreground process only while its run age is below the call's effective timeout, so a process past its execute deadline keeps running, and never to stop a background one (US6). When the agent has not received the call yet, it records the cancel and refuses a later start, so the call never runs, foreground or background, and the result says it was not run (US10). Other answers become: canceled by the user with partial output; the timed out result with background_process_id for a foreground process left running; the background start result for a background call; the real result of an exited process; or the recorded error of a start that failed, in the text the live start reports. A start that succeeded but whose process the agent does not report gives the background start result for a background call and unknown with the tool call UUID for a foreground call. Agent errors go through the same AgentErrorText as the execute tool from #29965: an agent restart, an unreachable agent, or an unreadable answer gives unknown with the tool call UUID to check or signal, while an old agent (404), a stale tool call, a chat without a workspace, a deleted workspace, and a workspace without an agent keep today's result. Other tools and billing are unchanged.

The answer-to-result mapping lives in chattool, next to the execute tool: ExecuteArgs.InterruptStopRunAge holds the stop rule, InterruptExecute sends the cancel and builds the result, and InterruptExecuteUnreachable covers a failed connection. tasks.go only selects the calls, collects the results, and commits them, so file tools (PR 6) plug into the same path. The decision ignores the message part buffer: the canceled generation goroutine records completions and publishes results before interrupt handling reads them, and the buffer is empty after an ownership change. New results are committed as non-error JSON, like every execute result, because chatprompt sends only the error field of an error result to the model, which would drop the partial output. Canceling on message edit is out of scope.

Part 5 of 6 of the CODAGT-757 stack.
Depends on #29965.

Refs https://linear.app/codercom/issue/CODAGT-757

Design: chatd and workspace agent tool call contract

CODAGT-757: contract between chatd and the workspace agent for tool side effects

Status: implemented by the six draft PRs in section 9. File and line references in sections 2 to 7 describe main at 9afdea4808, the base the design was written against.

0. Terms

Every term below has one meaning in this document. The last column lists earlier names (in this draft or in the #27369 stack) that the term replaces.

Term Meaning Replaces
model The LLM. Never called "agent" here.
workspace agent (agent) The Coder agent process running in the workspace.
tool call One call the model made, committed in an assistant message (existing term).
tool call message ID The chat message ID (chat_messages.id) of the assistant message that contains the tool call.
provider tool call ID The ID the model provider gave the tool call. Not guaranteed unique across messages.
unresolved tool call A tool call without a committed result (existing code term, unresolvedToolCallsFromHistory).
task retry chatd rerunning a generation attempt after an error or the 15m attempt timeout (existing, runTaskWithRetry).
ownership change Another coderd replica takes over the chat (existing).
tool call age Time since the tool call was committed, computed from the database clock. ID age, call_age
tool call headers Coder-Tool-Call-Message-Id, Coder-Tool-Call-Id, Coder-Tool-Call-Age-Ms, sent next to the existing Coder-Chat-Id on every request with a side effect. Idempotency-Key, op_id
tool call UUID A UUIDv5 of chat ID, tool call message ID, and provider tool call ID, computed by one shared workspacesdk function on both sides. Used as the process ID of a process the tool call starts, and as the ID in the cancel route.
latest message ID Per chat, the highest tool call message ID the agent has received.
agent start When the agent began keeping tool call records (agent process startup).
stale_tool_call Error code: the tool call's message is older than the chat's latest message ID, so chatd has already resolved it.
agent_started_after_tool_call Error code: the agent has no record of the tool call and started after the tool call was committed, so it cannot tell whether an earlier agent ran it. created_before_agent_start, unknown
tool_call_canceled Error code: a request with tool call headers arrived after a cancel recorded the tool call as canceled. Only a stale request can get it.
tool call middleware Agent HTTP middleware in agenttoolcall that applies the decision table to the start, edit, and write routes, runs the handler at most once per tool call, and records its response. per-route records
recorded response Status code, Content-Type, and body of the one run of a tool call's request. Repeated requests get it back unchanged. stored edit result
run age Time since the agent first ran a tool call's request, measured by the agent from the record's creation. A process started for the tool call takes that instant as its start time, so for execute the run age is the process age. Returned in the Coder-Tool-Call-Run-Age-Ms response header and in cancel answers. process age, age_ms
stop_if_run_age_below_ms Cancel parameter: stop a running process only if its run age is below this value. chatd passes the execute timeout for a foreground call and 0 (never stop) otherwise.
execute deadline When a foreground execute stops waiting: process start plus the timeout argument. budget, anchor
agent answer timeout How long chatd waits for the agent to answer one start, edit, write, or cancel request before it reports unknown: 1 minute. It bounds the wait for the answer, never the work. fallback deadline, backstop

Spelling: canceled, matching the codebase (514 uses versus 5 of cancelled in coderd, agent, codersdk).

1. User stories

Each story ends with the observable condition that proves it.

  • US1 Execute runs at most once and always gets a result. A tool call starts at most one process and gets exactly one committed result: completed, timed out (still running, with a process ID), canceled, not run, or unknown. Task retries and ownership changes neither start another process nor restart the timeout. Proof: one process per tool call UUID on the agent; a result committed by the execute deadline, or within the agent answer timeout when the agent does not answer.
  • US2 Interrupt stops foreground execute. The process is killed and the result says it was canceled by the user, with the partial output. Proof: the process is gone after the interrupt; the result is not "interrupted before it produced a result".
  • US3 The model knows whether an edit was applied. When an interrupt or task retry lands while edit_files or write_file is in flight, the result is the real edit result, "not applied", or "unknown" with the reason. (Design requirement: the contract must support it. Implemented in the final PR of the stack, after execute.)
  • US4 Task retries do not change results. A task retry or ownership change produces the result the model would have seen without it. Tool calls in a parallel batch that already finished are not run again.
  • US5 Unknown outcomes are reported as unknown. When chatd cannot learn whether a side effect happened (agent restarted, agent unreachable), the result says so and why. chatd never repeats the side effect and never claims it did not happen.
  • US6 Background processes survive interrupts. A process the model backgrounded, or a foreground command that hit its timeout, survives interrupts, task retries, and ownership changes. Only process_signal or agent shutdown ends it.
  • US7 Interrupts always finish. Interrupt handling finishes even when the agent is slow or unreachable. Waiting for the real outcome is preferred over finishing fast.
  • US8 Old agents work as today. New coderd with an old agent behaves no worse than today.
  • US9 Chats cannot touch each other's processes. A request carrying one chat's ID cannot read, signal, cancel, or reuse a process started by another chat.
  • US10 Interrupt before the agent receives a tool call. If the user interrupts before a start, edit, or write request reaches the workspace agent, the tool call never runs (foreground or background), and the model gets "not run" or "not applied". Proof: a request that arrives after the interrupt gets tool_call_canceled.

2. What breaks today

Trigger What happens Evidence
Task retry after the 15m attempt timeout while a tool runs Every unresolved tool call in the batch runs again; execute starts a new process each cycle tasks.go:28, tasks.go:155-174; CODAGT-757 incident, 36 starts in 9h (Linear, 2026-08-27)
Any task retry after tools ran, before CommitStep Same; results are committed together after the whole batch ARCHITECTURE.md:884
Ownership change mid-call The new owner runs unresolved tool calls again ARCHITECTURE.md:882 ("at least once")
User interrupt chatd stops waiting; the process keeps running; unresolved tool calls get "tool call was interrupted before it produced a result" tasks.go:316, tasks.go:696-773, message_conversion.go:27; ARCHITECTURE.md:880
Response from the agent lost chatd cannot refer to the process: the agent picks process IDs agent/agentproc/process.go:120

Root cause: the agent picks the ID of every side effect, so chatd cannot refer to it again without the original response. And chatd treats "I stopped waiting" as "the work stopped".

3. Who owns what

  1. chatd owns the conversation. The committed tool call is the durable record of intent. chatd adds no execution table.
  2. The agent owns side-effect state, and it decides whether to act. Only the agent can know whether it received a request before. chatd sends the same request on every attempt and never tries to tell a first attempt from a later one.
  3. chatd identifies the tool call on every request with the tool call headers on the existing routes. Both sides derive the process ID from the tool call, so chatd can address a process whose start response it never received.
  4. The agent keeps records only for the chat's latest batch. chatd only ever runs tool calls from a chat's latest assistant message. So a request for a newer message tells the agent that every older tool call in that chat is resolved.
  5. The agent reports durations, never timestamps. chatd never compares its clock with the agent's.

Rejected:

  • Postgres execution table (feat(coderd): track chat execute tool calls in a durable execution ledger #27100, feat(coderd/database): add a chat tool call execution ledger with bounded retention #27317-fix(coderd/x/chatd): keep task attempts alive while execute tools make progress #27325). chatd would copy state the agent owns, and would still need idempotent starts on the agent to cover a crash between starting a process and recording its ID. It needed claims, sweeps, and reconciliation.
  • Key beside agent-picked process IDs, with a fresh start whenever the agent has no record (feat: add a run-once reservation mechanism to the workspace agent process API #27369-feat: stop foreground execute processes when a user interrupts a chat #27372). After an agent restart, or once the record is evicted, a later attempt starts the command again.
  • The agent remembering every tool call it ever received (earlier revision). Memory grows with every tool call.
  • Only the first attempt may start; later attempts only look up (previous revision). The attempt number counts attempts, not requests that reached the agent. If attempts 1 to 4 fail before contacting the agent, attempt 5 would look up, get 404, and report "unknown" for a command that never ran. Letting any attempt start when the agent has no record is exactly the agent's own check, so the attempt number adds nothing. Knowing whether a request reached the agent would take a durable write before every request, which is the execution table again.
  • PUT to client-chosen URLs (earlier revision). It replaced existing routes; headers keep the existing API.

4. The contract

API

Request Route Change
start a process POST /api/v0/processes/start Existing route plus tool call headers, through the tool call middleware. The process ID is the tool call UUID. Response header Coder-Tool-Call-Run-Age-Ms.
wait for output GET /api/v0/processes/{id}/output Unchanged.
signal a process POST /api/v0/processes/{id}/signal Unchanged; used by the model's process_signal tool.
edit files POST /api/v0/edit-files Existing route plus tool call headers, through the tool call middleware (final PR).
write a file POST /api/v0/write-file Existing route plus tool call headers, through the tool call middleware (final PR).
cancel a tool call POST /api/v0/tool-calls/{id}/cancel New, one route for every tool. Tool call headers plus body {"stop_if_run_age_below_ms": N}. {id} is the tool call UUID. Returns whether the request ever ran, its recorded response, and the state of the process it started, if any.

Every tool with a side effect uses the same headers, the same middleware, and the same cancel route; only the process stop rule is tool specific.

What the agent does with a request

For a start, edit, or cancel request in chat C, for a tool call in message M:

Condition Start, edit, or write Cancel
M is older than C's latest message ID 409 stale_tool_call, nothing happens 409 stale_tool_call
The agent has a record of the tool call Return the recorded response (waiting for a run in progress); 409 input_mismatch if the request differs; 409 tool_call_canceled if a cancel recorded it Wait for a run in progress. If it started a process that is running with run age below stop_if_run_age_below_ms, kill it; otherwise leave it running. Return the recorded response and the process state
No record, and the agent started more than the tool call age plus a small margin ago Run the handler once, record its response, and raise C's latest message ID to M if higher Record the tool call as canceled so a request still in transit does nothing; report that it never started
No record, and the agent started less than that ago 409 agent_started_after_tool_call, nothing happens 409 agent_started_after_tool_call

When C's latest message ID rises, the agent drops the tool call records of older messages in C. Processes themselves are unaffected. A background process keeps running and stays addressable by its process ID for process_output and process_signal, and it is removed after exit as today.

Why "no record" proves "never received"

  • chatd runs only unresolved tool calls from the chat's latest assistant message: unresolvedToolCallsFromHistory reads the last assistant message only (generation.go:295-305). A new assistant message is generated only after every local tool call of the previous one has a result (generation.go:207-218).
  • Message IDs come from a sequence (chat_messages_id_seq, dump.sql:2082). Every transition locks the chat row before inserting (ARCHITECTURE.md:98), so IDs increase within a chat.
  • So a request for message M proves every tool call in older messages of that chat is resolved. The agent can drop their records and refuse requests for them.
  • The agent drops a record only when the latest message ID moves past it, and from then on it refuses that message outright. So if the agent has been running since before the tool call was committed and has no record for the latest message, it never received that tool call.
  • If the agent started after the tool call was committed, an earlier agent may have received it. Only then does the agent answer agent_started_after_tool_call.

State per chat: one latest message ID, plus records for that message's tool calls. It grows with the number of chats, not the number of tool calls.

Agent guarantees

  • A1 One process or edit per tool call. The agent acts on a tool call at most once in its lifetime. It inserts the record before spawning the process or applying the edit, so a concurrent request for the same tool call waits instead of acting twice.
  • A2 Work outlives the request that started it. A process or edit runs to completion when the HTTP connection closes. (True today: processes run under context.Background(), process.go:126-129; edits do not check the request context.)
  • A3 The agent decides as in the table above, from the tool call headers, its records, and how long ago it started.
  • A4 Cancel leaves a final state. After cancel returns, the tool call's state does not change, except that a process cancel chose to leave running keeps running. An edit in progress cannot be stopped; cancel waits for it and returns its result. Deciding whether to stop a process happens on the agent, against the process's own run age, in the same step as the kill.
  • A5 Chat isolation. Records are looked up by chat ID first. Another chat's process ID gets the same 404 as an unknown one. Today's rule for output and signal (api.go:167-177, api.go:228-237) stays. Cancel needs no ownership lookup: its path ID must equal the UUID derived from the request's own chat ID and headers, otherwise 400. Requests with tool call headers but without Coder-Chat-Id are rejected with 400. The tool call UUID includes the chat ID, so two chats never derive the same process ID.
  • A6 Durations only. The agent reads Coder-Tool-Call-Age-Ms and returns run ages (Coder-Tool-Call-Run-Age-Ms, and in cancel answers). Both are durations.
  • A7 Records live in agent memory only. An agent restart loses them and, as today, kills the agent's processes. Per-chat state is kept for the agent's lifetime. If a memory cap is ever added, evicting a chat's state must count as a restart for that chat, so later requests get agent_started_after_tool_call rather than a second run.

chatd obligations

  • C1 Identify every request. chatd sends the tool call headers on every start, edit, and cancel. It computes the tool call age from the database clock. The SDK connects to the agent before it sets the age header and adds the time spent connecting, and the agent compares the age with its uptime when the request arrives, before it reads the body. Waiting for a restarting agent or reading a large body therefore does not make the age stale.
  • C2 Every attempt sends the same request. chatd does not track whether an earlier attempt reached the agent.
  • C3 Only the latest message. chatd sends requests only for tool calls in the chat's latest assistant message. This is true today, and the agent relies on it.
  • C4 Stopping a wait never stops the work. When a chatd context ends (task retry, ownership change, shutdown), chatd stops waiting and sends nothing to the agent.
  • C5 Only a user interrupt cancels. Interrupt handling (StartInterrupt) sends one CancelToolCall for every unresolved tool call whose tool sends tool call headers (execute; edit_files and write_file in the final PR), with stop_if_run_age_below_ms set to the effective timeout for a foreground execute and 0 otherwise, and commits results built from the answers. It waits for the answers; its timeout exists only so an unreachable agent yields "unknown". A process past its execute deadline, or started in the background, is never stopped (US6).
  • C6 Deadlines from the agent's clock. Execute deadline = now + timeout - run age. Every start, edit, write, and cancel request waits at most the agent answer timeout for its answer; when that expires while the task still runs, the result is unknown with the tool call UUID. When the wait for output reaches the execute deadline and the output cannot be read either, the result says the outcome is unknown, not that the command timed out.
  • C7 One result per tool call.
Agent answer execute result edit_files result
exited / recorded edit response exit code and output the edit result
running at the execute deadline, or left running by cancel timed out, still running, background_process_id (today's behavior); background: the background start result n/a
canceled after it started canceled by the user after the run age, partial output n/a (edits finish instead)
canceled before it arrived not run (foreground and background) not applied
409 agent_started_after_tool_call unknown: the workspace agent restarted after this tool call; the command may have run unknown: may have been applied; check the file
409 stale_tool_call or tool_call_canceled none: only a stale goroutine can get this, and its commit fails the history-version fence same
agent unreachable unknown: the agent could not be reached unknown, same reason

Old agents

  • Start and edit. An old agent ignores the tool call headers and returns a random process ID, so every attempt behaves exactly as today, including a second start after a task retry. chatd can tell an old agent by the returned process ID differing from the tool call UUID.
  • Cancel. It gets a plain 404 (no such route), and chatd falls back to today's interrupt result.

I confirmed with a throwaway chi test, mirroring today's process routes, that the new paths return 404. The test has been removed.

Inside the agent

The record store is one per agent, in agenttoolcall: keyed by chat ID, tool call message ID, and provider tool call ID, holding each chat's latest message ID, the agent start, and each tool call's recorded response. The tool call middleware wraps the start, edit, and write routes, so those handlers stay unaware of repeats. The one cancel handler lives next to the store and asks process management to stop a tool call's process under the run-age rule; file tools need no stop step. Process management keeps owning output, exit state, and signals.

5. Tool coverage

  • In this stack: execute (foreground and background) first, because it has the worst failure today: duplicate processes that keep running. edit_files and write_file in the final PR, using the same headers, decision table, and cancel route. write_file replaces the whole file (agent/agentfiles/files.go:302, files.go:574-629), so repeating it is less harmful than repeating edit_files, but it follows the same contract.
  • Designed for, not in this stack: computer-use actions and workspace MCP tool calls. Same contract.
  • No headers needed: read_file, ls, process_output, process_list. They have no side effect, so running them again is harmless.
  • Outside this contract: side effects inside coderd, such as spawn_agent, attach_file (CODAGT-875), and create_workspace. The same idea (identity from the tool call, enforced by a database constraint) applies separately.

6. Relation to the current stack

Keep Change Drop
Records in agent memory, scoped by chat The tool call identity moves into headers, and the process ID is derived from it; no key-to-process registry Signal-by-idempotency-key route
Identity from the tool call message ID plus the provider tool call ID With no record, the agent starts only if it has been running since before the tool call was committed; otherwise agent_started_after_tool_call #27371 watchdog: a later attempt has no side effect
input_mismatch Records are dropped when the chat's latest message ID rises, not after a time Committed error result for start_pending
Task retries and ownership changes never cancel; background processes are never canceled Cancel records unknown tool calls as canceled; #27372 reads a 404 as "already gone" StartedAt compared against coderd's clock
A later attempt waits on the existing process for the rest of the timeout Interrupt handling sends cancel and builds the result; #27372 kills from a deferred call in the tool goroutine being canceled, while the committed result stays generic 60-minute time-based retention
edit_files and write_file use the same shape (final PR)

The message ID was questioned in #27370 review as possibly unnecessary. It is now required: it is how the agent knows which records it may drop.

7. Residual risks

  • An agent restart kills running processes and loses their output. Later attempts get agent_started_after_tool_call and report "unknown". Persisting records to disk would not bring the processes back.
  • False "unknown": a tool call committed shortly before the agent started gets agent_started_after_tool_call even if no request ever reached an earlier agent. That takes an agent restart between committing the tool call and its first request, or a request landing within the margin right after workspace start.
  • A slow agent can turn a cancel into "unknown", and the work may finish later.
  • A stale request for a tool call resolved without a cancel (a message edit that discards the branch, Q4) can still act if it arrives before the chat's next message. Same as today; Q4 follow-up.
  • The age check is best effort. It assumes a request reaches the agent within the 2s margin after chatd measured the age. The SDK measures after connecting and the agent measures on arrival, so only writing the request headers and network transit count. A request delayed longer than the margin right after an agent restart can run a second time. Closing that gap needs an agent incarnation token stored per tool call before its first request, which is a durable write before every request (section 3, rejected).
  • Mixed coderd versions during a rollout: if an old coderd sent a tool call's request without tool call headers and a new coderd handles the interrupt, the agent has no record for the tool call and answers "never started", so the result can say "not run" or "not applied" while the old request's process or edit happened. Only a user interrupt landing in that window is affected.
  • Writes with tool call headers are buffered in agent memory to hash them before acting (chatd already holds the content in memory; any client that can reach the agent API can already run commands). Without headers, /write-file still streams.
  • Multi-file edit_files can partially apply on a disk error (agent/agentfiles/files.go:452-454). The recorded result is the handler's existing error response, which does not list the files written before the failure. Unchanged behavior; the recording only replays it.
  • The 15m attempt timeout still exists. It no longer causes side effects, but each cycle prepares and dials again. Removing it is CODAGT-749/750.
  • ARCHITECTURE.md:880-882 ("best-effort" interrupts, "at least once") would change. Per repo rules, the implementing PR leaves TODOs there for the human author.

8. Decisions and open questions

Decided (operator, 2026-09-25):

  • Q1 A later attempt never repeats a side effect without proof it did not happen. The agent provides the proof (section 4). feat(coderd/x/chatd): identify tool calls in the dispatcher and dedupe execute replays #27370's fresh start whenever the agent has no record is dropped.
  • Q2 Interrupt cancels foreground execute only. A process_output wait on a background process has no side effect and is not canceled. If CODAGT-1108's combined process view confuses users later, extending C5 is a small follow-up. (From the final PR on, unresolved edit_files and write_file calls also get a cancel, but only to learn their state; an edit in progress still finishes.)
  • Q3 Interrupt handling waits for cancel's answer. Reporting the real outcome matters more than a fast interrupt.
  • Q4 Canceling foreground work in a branch discarded by a message edit is out of scope. Correct in principle, but nothing owns it today. Follow-up work.
  • Q5 The attempt number is rejected as the way to tell a first request from a later one (section 3).
  • Keep the existing routes and identify the tool call in headers, not PUT (operator, 2026-09-25).
  • Q6 execute first; edit_files and write_file in the final PR of the stack. They are in the design so the contract is general, not execute-shaped. write_file gets no special exception.
  • Q7 Three Coder-Tool-Call-* headers, not one structured header that the agent would have to parse.
  • Q8 Revision 2 (operator, 2026-09-26): one cancel route with a run-age stop rule, one tool call middleware on the agent, and the chatd cleanups from an independent review, plus US10. Section 9 "Revision 2" is the spec; it supersedes the per-route cancel routes and per-package records described in the PR subsections below where they conflict.
  • Q9 Final review fixes (orchestrator, 2026-09-27, from the cross-stack review). (a) Stale age after a restart: the SDK sets the age after connecting and the agent reads its uptime on arrival (C1, section 7). (b) The fallback deadline was never implemented, so a connected agent that never answers left background starts and file edits retrying every 15 minutes forever. It is replaced by the agent answer timeout, which bounds each request instead of the whole tool call, so every attempt ends with an answer or unknown without age arithmetic across attempts (US1). (c) A failed output read after the execute deadline no longer claims the command timed out (US5).

Open: none.

9. Implementation stack (revision 2)

This section is the single source of truth for names, signatures, and PR boundaries. Anything not listed here is out of scope; a deviation needs a user story (section 1 style) that motivates it. Revision 2 (Q8) replaces the per-route cancel routes, the per-package record tables, and the age_ms body fields of revision 1. All six PRs are published drafts; rework lands as new commits and children merge their parents (no force-push).

PRs

# PR Branch (mafredri/codagt-757/...) Base Content after revision 2
1 #29946 01-sdk-tool-call main codersdk/workspacesdk: tool call identity, headers (incl. run age), UUID, per-request headers, error codes, CancelToolCall client.
2 #29954 02-agent-tool-call-records 1 agent/agenttoolcall: the record store, the tool call middleware, the cancel handler, the error table.
3 #29958 03-agent-process-tool-calls 2 agent/agentproc and wiring: start runs through the middleware with the tool call UUID as process ID; the process stop rule; reaping rule.
4 #29965 04-chatd-execute-tool-calls 3 coderd/x/chatd: dispatch identity, tool call age, execute sends headers, attaches, deadline from run age, result mapping, the shared error classifier.
5 #29976 05-chatd-interrupt-cancel 4 coderd/x/chatd: interrupt handling sends CancelToolCall for every unresolved tool call and commits results from the answers.
6 #29978 06-file-tool-calls 5 edit_files and write_file through the same middleware and cancel route; SDK result rebuilding; chatd file tools.

PR 1: SDK (codersdk/workspacesdk)

Unchanged from revision 1: ToolCall, SetHeaders, ToolCallFromHeaders, ToolCallUUIDNamespace, ToolCallUUID (test vector: chat aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee, message 42, tool call "toolu_01/%x y" gives 1a9cb501-66b5-51bd-a7c6-1bf0e500b268), WithToolCall, ToolCallFromContext, ToolCallErrorCode and its four codes, ToolCallError, and the coded-409 decoding helper.

Changes:

const CoderToolCallRunAgeMsHeader = "Coder-Tool-Call-Run-Age-Ms" // decimal int64 >= 0, set by the agent on the recorded or replayed response of a tool call's request and on a cancel answer for a tool call that ran

// StartProcessResponse: the revision 1 AgeMs body field is removed. The
// body of a replayed start is the recorded one, so a live value travels in
// the response header instead.
type StartProcessResponse struct {
	ID      string        `json:"id"`
	Started bool          `json:"started"`
	RunAge  time.Duration `json:"-"` // from CoderToolCallRunAgeMsHeader; zero when absent (old agent or no tool call headers)
}
// ProcessOutputResponse.AgeMs is removed (no consumer after revision 2).

type CancelToolCallRequest struct {
	// StopIfRunAgeBelowMs stops a running process only if its run age is
	// below this value. 0 never stops.
	StopIfRunAgeBelowMs int64 `json:"stop_if_run_age_below_ms"`
}

type CancelToolCallResponse struct {
	// Started is false when the agent never ran the tool call's request.
	// The agent has recorded it as canceled, so it never will.
	Started bool `json:"started"`
	// StatusCode, ContentType, and Body are the recorded response of the
	// tool call's request when Started. Body round-trips byte for byte.
	StatusCode  int    `json:"status_code,omitempty"`
	ContentType string `json:"content_type,omitempty"`
	Body        []byte `json:"body,omitempty"`
	// Process is the state of the process the tool call started, if any.
	Process *ToolCallProcess `json:"process,omitempty"`
}

type ToolCallProcess struct {
	Running   bool               `json:"running"`
	Canceled  bool               `json:"canceled"` // a cancel request killed it; repeated cancels report it too
	Output    string             `json:"output,omitempty"`
	Truncated *ProcessTruncation `json:"truncated,omitempty"`
	ExitCode  *int               `json:"exit_code,omitempty"`
	RunAgeMs  int64              `json:"run_age_ms"`
}
  • Requests with a tool call in context connect to the agent first (reachability wait and dial), then set the tool call headers with Age plus the time spent connecting, and send over that connection. Requests without a tool call are unchanged.
  • AgentConn: CancelProcess and CancelProcessResponse are replaced by CancelToolCall(ctx context.Context, id string, req CancelToolCallRequest) (CancelToolCallResponse, error): POST /api/v0/tool-calls/{id}/cancel, headers from WithToolCall, coded 409s as *ToolCallError. StartProcess fills RunAge from the header. Regenerate agentconnmock.

PR 2: record store, middleware, cancel handler (agent/agenttoolcall)

Revision 1's generic Records[V] and PR 6's Chats merge into one store of recorded responses. The decision rules and their order are unchanged (stale; record exists; clock.Since(start) - ageMargin <= age means the agent started after the tool call; otherwise run once), ageMargin = 2 * time.Second, ages clamped at 0, per-chat state kept for the agent's lifetime, older messages' records dropped when a chat's latest message ID rises, waiters wake on success, error, and panic.

  • type Store, one per agent, func NewStore(clock quartz.Clock) *Store (the agent start is clock.Now()).
  • func (s *Store) Middleware(next http.Handler) http.Handler:
    • No tool call headers: calls next unchanged.
    • Headers present: 400 without chat context (agentchat.FromContext) or with malformed headers. Input hash = SHA-256 over method, URL path, raw query, and the full request body (the body is buffered; a new body reader is handed to next).
    • A new record: puts the tool call (key and UUID) into the request context for next, runs next once with a response recorder, records status, Content-Type, and body, and writes them to the client. Repeated requests get the recorded response. Every answered response (new or replayed) carries Coder-Tool-Call-Run-Age-Ms, the time since the record was created on the store's clock.
    • Sentinel errors are written as 409 ToolCallError bodies from one table of codes and sentence-case messages in this package.
    • A request whose context ends while waiting for another request's run returns without writing.
  • The middleware and the cancel handler read the store clock when they are entered, before reading the body, and use that time for the "agent started after the tool call" rule.
  • func FromContext(ctx context.Context) (ToolCall, bool) for handlers, where ToolCall holds the Key and the tool call UUID.
  • func (s *Store) Current(key Key) bool (the key is in its chat's latest message and has a record).
  • The cancel handler, mounted at POST /api/v0/tool-calls/{id}/cancel:
    // ProcessStopper stops the process a tool call started, under the run-age rule.
    type ProcessStopper interface {
    	StopToolCallProcess(ctx context.Context, processID string, stopIfRunAgeBelow time.Duration) (state workspacesdk.ToolCallProcess, found bool, err error)
    }
    func (s *Store) CancelHandler(stopper ProcessStopper) http.HandlerFunc
    Chat context and tool call headers required (400); {id} must equal the UUID derived from the request's own chat ID and headers (400). No record and provable: record canceled, answer started: false. Pending run: wait. Started: ask stopper (nil allowed), answer the recorded response plus Process when found. Sentinel errors: 409 codes. Request context ended: return without writing.

PR 3: agent processes (agent/agentproc, agent/agent.go, agent/api.go)

  • The start route is wrapped by store.Middleware (store passed to agentproc.NewAPI through an option). With a tool call in context, manager.start uses the tool call UUID as process ID and remembers the key; otherwise unchanged.
  • agentproc implements ProcessStopper: find the process by ID; running with run age below the threshold: SIGKILL the process group, mark it canceled by a cancel, wait for exit (bounded by ctx); running at or above: leave it; exited: final state. Report Canceled for a process a cancel killed, on every later cancel too. Run age from the process's own start time on the manager clock.
  • Reaping: an exited process whose key is store.Current is not reaped; otherwise the 5-minute rule is unchanged.
  • Removed: revision 1's own records, header parsing, POST /processes/{id}/cancel, age_ms in bodies, WithToolCallChats.
  • Wiring: agent/agent.go builds one agenttoolcall.Store on the agent clock; agent/api.go mounts the cancel route with processAPI as stopper.
  • Tests keep the revision 1 guarantees (one process per tool call proven on disk, concurrent starts, cancel while a start is pending, Close kills, one git watcher notification, chat isolation) and add the run-age rule: below threshold kills, at or above leaves running, 0 never stops.

PR 4: chatd execute (coderd/x/chatd)

Unchanged from revision 1 except:

  • The execute deadline uses StartProcessResponse.RunAge (clamped at 0).
  • One constant in chattool holds the agent answer timeout (1 minute). Start requests wait at most that long; when it expires while the tool's context is still live, the result is unknown with the tool call UUID. The foreground start still also ends at the execute timeout, whichever comes first.
  • When the wait for output reaches the execute deadline and the output snapshot fails too, the result says the result was not received and the outcome is unknown, with the process ID, instead of "command timed out".
  • Cleanups: one helper maps an agent request error to a result, shared with PR 5 and PR 6 (only wording differs per tool); processSnapshotResult removed (waitForProcess with an expired context gives the same result and texts; prove it with the existing tests); chatloop takes one batch-level ToolCallIdentity instead of three fields and the toolCallBatch type; small helpers for attaching headers, the connection-error check, age calculation, the default timeout, and message reload, each only where it removes duplication.
  • Everything else stays: the classifier, the unknown texts, the connection-failure rules (no workspace, deleted workspace, and no agent for execute keep today's result), input_mismatch wording, the regression tests, the one ARCHITECTURE.md TODO line.

PR 5: chatd interrupt (coderd/x/chatd)

  • One path for every unresolved tool call whose tool sends tool call headers: one CancelToolCall per call, in parallel under the agent answer timeout, outside the locked transaction. stop_if_run_age_below_ms: the effective timeout for a foreground execute, 0 for background execute (and for file tools in PR 6).
  • The per-tool mapping from a cancel answer to a result lives in chattool; tasks.go only collects results and commits them. Foreground: not started gives "not run"; Process.Canceled gives canceled by the user after the run age with partial output; Process.Running gives the timed-out, still running result with background_process_id; exited gives the real result. Background: not started gives "not run" (US10); running or exited gives the background start result with background_process_id. A start recorded as successful whose process the agent no longer reports gives the background start result (background) or unknown with the tool call UUID (foreground). Errors go through PR 4's classifier; old agent (404 without a code), no workspace, deleted workspace, and no agent for execute keep today's result; unreachable and unreadable give unknown with the tool call UUID.
  • Removed: the ProcessOutput pre-checks, InterruptBackgroundExecute, the per-tool branching in tasks.go.
  • Kept: the answer replaces any buffered result (exactly one result per call), duplicate provider IDs handled only when every duplicate qualifies, parent-context cancellation commits nothing, the runner-driven test, the one ARCHITECTURE.md TODO line.

PR 6: file tools

  • Agent: POST /edit-files and POST /write-file are wrapped by the same store.Middleware (store passed to agentfiles.NewAPI through an option). Removed: agentfiles/toolcall.go, the fileResult refactor in files.go, per-route input hashing, the file cancel routes, WithToolCallChats, the Chats type. The file handlers go back to today's code apart from what the middleware needs.
  • SDK: EditFiles and WriteFile return *ToolCallError for coded 409s; CancelToolCallResponse gets methods that rebuild exactly what EditFiles or WriteFile would have returned from the recorded response. CancelEditFiles and CancelWriteFile are removed.
  • chatd: edit_files and write_file keep their identity, classifier, and wording from revision 1; interrupt handling uses PR 5's one path with stop_if_run_age_below_ms 0 and PR 6's result rebuilding. chattool.InterruptedCall decides per tool whether a call gets a cancel and maps the answer, so tasks.go has no tool switch and one converter for every committed interrupt result. The two ARCHITECTURE.md TODO lines cover file tools.
  • Edit and write requests wait at most the agent answer timeout, with the same unknown result on expiry.
  • The converter truncates every committed interrupt result with the budget generation uses for live tool results (chatloop.TruncateToolResult). Story: an interrupted edit_files call whose recorded result holds a multi-megabyte diff would otherwise be committed uncut and overflow the next prompt, while the same result arriving live is truncated.
  • TestAgent_ToolCalls (real AgentConn through the agent router) covers the one cancel route for a process and an edit, a stale request, and the run-age rule.

🤖 This PR was created with the help of Coder Agents, and will be reviewed by a human. 🏂🏻

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@linear-code

linear-code Bot commented Sep 26, 2026

Copy link
Copy Markdown

CODAGT-757

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@github-actions github-actions Bot assigned mafredri Sep 26, 2026
@mafredri mafredri mentioned this pull request Sep 26, 2026
feat: run file edit and write tool calls at most once #29978
Closed
@mafredri
mafredri added this pull request to stack #29985 September 26, 2026 12:50
This was referenced Sep 26, 2026
feat: run chat tool calls once per tool call ID on the agent #29946
Merged
feat(coderd/x/chatd): run execute at most once per tool call #29965
Closed
feat: send tool call IDs and cancel unresolved calls on interrupt #29954
Merged
feat: run tool call processes at most once #29958
Closed
@mafredri mafredri changed the title feat(coderd/x/chatd): cancel foreground execute on user interrupt feat(coderd/x/chatd): resolve execute tool calls on user interrupt Sep 27, 2026
@mafredri
mafredri force-pushed the mafredri/codagt-757/04-chatd-execute-tool-calls branch 3 times, most recently from ebecc64 to 62a44a8 Compare September 28, 2026 11:24
@mafredri
mafredri force-pushed the mafredri/codagt-757/05-chatd-interrupt-cancel branch from 2094e2f to aae8893 Compare September 28, 2026 11:26
@mafredri
feat(coderd/x/chatd): cancel unresolved execute calls in an interrupt…
7e709ed
… run

An interrupt left the processes of unresolved execute calls running
and committed "tool call was interrupted before it produced a result"
for them. For a capable workspace agent, the interrupt task now runs
the interrupt run before FinishInterruption: it dispatches every
unresolved call of a tool that sends tool call headers through
chatloop.ExecuteLocalTools with the interrupt cause, using the tools
generation builds, so each request matches the one the agent recorded.

In the interrupt run, execute cancels the tool call first. After a
204 it sends its start again and builds its normal result: canceled
with partial output, completed, not run when the agent never received
the start, or unknown. A background call reads its output once, and
the output wait is bounded by the agent answer timeout. A failed
cancel is never followed by the start: a plain 404 keeps the generic
result, anything else is unknown. An agent that is not capable gets
no request and every call keeps the generic result. A canceled task
context commits nothing.
@mafredri
mafredri force-pushed the mafredri/codagt-757/05-chatd-interrupt-cancel branch from aae8893 to 7e709ed Compare September 28, 2026 11:29
@mafredri

mafredri commented Sep 28, 2026

Copy link
Copy Markdown
Member Author

Superseded: the CODAGT-757 work is now two PRs, #29946 (agent and SDK) and #29954 (chatd).

🤖 This comment was created with the help of Coder Agents.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@mafredri mafredri closed this Sep 28, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 28, 2026
@mafredri
mafredri removed this pull request from stack #29985 September 28, 2026 13:06
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Reviewers

No reviews

Uh oh!

There was an error while loading. Please reload this page.

Assignees

@mafredri mafredri

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

Uh oh!

There was an error while loading. Please reload this page.

1 participant

@mafredri
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.