GeezerVisonThe World Wide Web, restored to 800 pixels of glory. | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Home|About|Gaming cabinet|Archives|How it works|Starter sites | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
GeezerVison is showing github.com — feat: run chat tool calls once per tool call ID on the agent by mafredri · Pull Request #29946 · coder/coder Conversation
|
| Term | Meaning |
|---|---|
| tool call ID | A UUIDv5 of the chat ID, the assistant message ID, and the provider tool call ID, computed by chatd. Every replica and every attempt computes the same value from committed rows. For execute it is also the process ID. |
Coder-Tool-Call-Id |
The request header that carries the tool call ID on start, edit, and write requests. |
| unresolved tool call | A tool call in the latest assistant message without a committed result (existing term). |
| saved response | The status, Content-Type, and body the agent returned for a tool call ID the first time. |
execute, the running or exited process) instead of running again. Holds while the agent keeps running.execute. Foreground and background processes of unresolved calls are killed. The result is "tool call was canceled while running" with the output so far, or the real result if the command had finished.waitUntil from the first start, so a retried foreground execute waits only for the rest of its timeout.agent/agenttoolcall, agentproc)Coder-Tool-Call-Id is read by agentchat.Middleware into agentchat.Context.ToolCallID, next to the chat ID (and logged as tool_call_id). Handlers and the middleware below read it from the context.
A table keyed by chat ID and tool call ID. A record is running (with a channel that closes when the run finishes), done (with the saved response), or canceled.
One middleware, registered once for the whole agent API right after agentchat.Middleware: a request whose context carries a tool call ID runs once per chat and ID. Without a tool call ID: unchanged. With it:
| Record | Action |
|---|---|
| none | run the handler once, save its response, return it |
| running or done | wait, return the saved response |
| canceled | 409, do nothing |
A handler panic saves a 500 saying the outcome is unknown, so repeats and cancels do not wait forever.
POST /api/v0/tool-calls/{id}/cancel, mounted from Table.Routes(): no record stores a canceled record, kills a process with that ID (it outlives the record), and responds with received: false. A record is marked canceled, the cancel waits for the run to finish, kills its process if the ID has one, and responds with received: true and the saved response.
Processes are keyed by chat ID and process ID. A process started with a tool call ID uses it as its process ID; a start for a key that already has a process returns that process. Output, signal, list, and kill see only the requester's chat's processes. The start request's timeout_ms sets the process's waitUntil at the first start; a repeat does not move it, and the process keeps running after it. A start with both background and a non-zero timeout_ms is rejected with 400, since only regular output waits read background processes. A blocking output wait with timeout_from_start_process=true (the execute tool's own wait) returns at exit, at waitUntil, or at the existing 5-minute cap; other waits, such as process_output's, ignore waitUntil. The output response reports timed_out (running past waitUntil) and canceled (killed by a cancel).
Adding a record deletes records that no request or cancel has used for an hour (forgetAfter), to bound memory. chatd resends every unresolved call on each retry, and a long batch is retried about every 15 minutes, so a batch that chatd is still retrying keeps its records.
Exited processes stay listed for an hour after they exit (5 minutes on main). Redesigning this retention is CODAGT-1220.
codersdk/workspacesdk)CoderToolCallIDHeader, WithToolCallID(ctx, id), ToolCallIDFromContext(ctx). Only StartProcess, EditFiles, and WriteFile send the header, because only they act.CancelToolCall(ctx, id) (CancelToolCallResponse, error); CancelToolCallResponse{Received bool; Status int; ContentType string; Body []byte} with one decoder per saved response, each calling the decode code of the method it mirrors: StartProcessResult(), EditFilesResult(), WriteFileResult().StartProcessRequest.TimeoutMs; ProcessOutputOptions.TimeoutFromStartProcess; ProcessOutputResponse.TimedOut, Canceled.coderd/x/chatd)The dispatcher gives each tool call's context its tool call ID (chattool.ToolCallIDs), attached with WithToolCallID. Only the three acting SDK methods send it. A call whose provider tool call ID is empty or repeats in the message gets none and behaves as today, since calls sharing an ID would get each other's saved responses.
execute foreground sends timeout_ms, waits with timeout_from_start_process=true, and waits until the output response reports exit or timed_out. chatd's own timeout, counted from each attempt, stays as a backstop, which gives an old agent (it ignores timeout_ms) today's behavior.
The interrupt task reads the unresolved execute, edit_files, and write_file calls that have a tool call ID in its locked read (the same read that checks the chat is still in the state the task started for). After releasing the lock, it dials the agent once and sends one cancel per call, all at once, under one 30-second timeout. A cancel waits for a running call to finish, so sending them at once keeps one slow call from using up the others' time. A panic in one cancel gives that call the generic text. Results:
| Response | execute |
edit_files / write_file |
|---|---|---|
received: false |
read the output: not found gives the plain-text error tool call was canceled before it ran; no changes were made; found is handled like a saved success | tool call was canceled before it ran; no changes were made |
| saved error response | the start error, as today | the edit or write error, as today |
| saved success | read the output after the process exits (the kill does not wait for the exit): canceled gives tool call was canceled while running with the output so far, marked "canceled": true (the UI shows the killed marker); exited gives the real result |
the edit or write result |
| 404, error, timeout | today's generic text, and a warning log | today's generic text, and a warning log |
Results go into the existing commit path (committedPendingLocalToolCancellationMessages, FinishInterruption) as a map by provider tool call ID.
Retries keep today's behavior (the tool runs again):
sh process only, as process_signal does today.An execute that finished early in a batch that stays unresolved (for example next to a long sibling) keeps its record while chatd retries, but its process is removed an hour after it exits, the next time any chat lists processes. Later retries then get an output error ("get process output: ...") instead of its result. It does not run again, but its output is lost (CODAGT-1220).
After a retry, an execute result's wall_duration_ms counts only the last attempt (a 20-minute timeout showed "for 5m" in UAT): chatd measures it per attempt, and the agent's responses do not carry the process's start time. The timeout itself counts from the first start (CODAGT-1243).
Interrupts give a false "canceled before it ran" when the agent has no record of a call that did reach it. Today these cases give the generic text. Accepted to keep the change small; a complete fix needs a record that survives the agent process or a comparison of the call's age with the agent's uptime, both rejected (section 6):
execute whose process exited more than an hour earlier and was dropped from the process list after the record was deleted.A workspace that cannot be reached during the interrupt records no cancel, so a request that arrives later still runs. The committed result is today's generic text.
mafredri/codagt-757/01-sdk-tool-call: agent and SDK.mafredri/codagt-757/02-agent-tool-call-records: chatd.#29958, #29965, #29976, and #29978 are closed as superseded. Branches are rewritten from main and force-pushed (operator approval, 2026-09-28).
waitUntil (accepted earlier).received: false for execute falls back to the process list, and the remaining false results are documented in section 4 rather than fixed. Attach and kill respect chat boundaries.write_file is atomic, so a cut-off upload saves a true error); retention counted from run end (recorded runs are short; the process list covers execute); restricting IDs to three tools and per-route middleware (the SDK sends the header only from the three acting methods); a mixed clock (already one clock); reporting time left on retry (the timeout counts from the first start).waitUntil (timeout_from_execute=true); process_output waits keep their own timeout. A background start with a timeout is rejected (operator, after the first review round).entryMaxAge is renamed forgetAfter.execute, an edit's record could be deleted while chatd still retried the batch, and the edit ran again. Every request or cancel that finds a record now resets its age. This replaces the earlier rejection of retention counted from run end.execute cards read "Ran ... for 0ms"): the canceled result passes the agent's canceled flag through as "canceled": true, and the UI shows it with the marker it already uses for commands killed by process_signal; "not run" is a plain-text error like the edits' "not applied" ("Failed to run"); a zero wall_duration_ms is left out. Rejected: marking the canceled JSON result is_error, which says "Failed to run" for a command that ran, makes interrupts the only execute failures flagged as errors, and would drop the output from the model on replay (only the error field of an is_error JSON part reaches it). The collapsed card not marking any failed command (exit code, timeout) is a separate UI question. The per-attempt duration after a retry is documented in section 4 (operator: a chatd and agent API issue; a WebSocket between them in the future would remove the blindness, and also the start-then-wait of execute).tool call was canceled before it ran; no changes were made and tool call was canceled while running. CancelToolCall has a case per tool name and rejects other tools before sending. The agent's cancel hook is named cancel, not kill, because it also serves edits. ARCHITECTURE.md states the at-most-once exception and the interrupt's cancel step instead of a TODO. Comments keep only what the code does not show. TimeoutFromExecute (timeout_from_execute) is renamed TimeoutFromStartProcess (timeout_from_start_process), because it is part of the agent process API and should not be named after a chatd tool.🤖 This PR was created with the help of Coder Agents, and will be reviewed by a human. 🏂🏻
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
62535b5 to
c7b47ba
Compare
eda760c to
026131a
Compare
|
@codex review |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Here are some automated review suggestions for this pull request.
Reviewed commit: 12924f189b
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Expiration and process reaping can return stale process IDs, while the existing concurrent cross-chat start race remains unresolved.
Review effort: Balanced
Findings: 3
Adds agent-side deduplication and cancellation for chat tool calls, including process timeout and ownership tracking.
Changes:
| File | Description |
|---|---|
agent/agent.go |
Initializes tool-call tracking. |
agent/api.go |
Registers middleware and cancellation routes. |
agent/agentchat/headers.go |
Parses tool-call headers. |
agent/agentchat/log.go |
Adds tool-call context and logging. |
agent/agentchat/log_test.go |
Tests tool-call log fields. |
agent/agentproc/api.go |
Adds process timeout and cancellation behavior. |
agent/agentproc/api_test.go |
Tests process attachment and timeout validation. |
agent/agentproc/process.go |
Associates processes with tool-call IDs. |
agent/agenttoolcall/agenttoolcall.go |
Implements run-once and cancellation records. |
agent/agenttoolcall/agenttoolcall_test.go |
Tests deduplication and cancellation concurrency. |
agent/agent_test.go |
Adds end-to-end agent behavior tests. |
codersdk/workspacesdk/agentconn.go |
Exposes headers, cancellation, and timeout fields. |
codersdk/workspacesdk/agentconnmock/agentconnmock.go |
Updates the generated SDK mock. |
codersdk/workspacesdk/toolcall.go |
Defines tool-call context and saved-response decoding. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
|
@codex review |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Here are some automated review suggestions for this pull request.
Reviewed commit: b616076fd2
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
|
@codex review |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
Codex Review: Didn't find any major issues. Chef's kiss. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
chatd retries and ownership changes can resend a start, edit, or write
for a tool call that already ran, and an interrupt cannot tell whether
the agent received the call. StartProcess, EditFiles, and WriteFile now
send Coder-Tool-Call-Id; the agent runs a request with it once per chat
and tool call ID and answers repeats with the saved response.
POST /api/v0/tool-calls/{id}/cancel refuses calls the agent never
received and kills the process of calls it did. The agent keeps the
execute deadline from timeout_ms, so a retried start does not restart
it, and process output reports timed_out and canceled.
A cancel for a tool call the agent has no record of answered "not received" even when a process started with that ID was still running, for example after the one-hour record expired. The cancel now kills such a process too, so chatd can read its output instead of reporting "not run". A handler panic left the entry running forever, so repeats and cancels for that ID waited until their callers gave up. The run now saves a 500 saying the outcome is unknown before the panic reaches httpmw.Recover. Attaching to a process and killing it looked the process up by ID only. Both now require the process to belong to the requesting chat, as the output and signal routes already do.
Every blocking output wait on a process started with timeout_ms ended at that timeout. After a foreground execute timed out, a later process_output wait on the same process therefore returned at once instead of waiting for its own timeout. Only the execute tool's own wait should stop there, so it now asks for that with stop_at_start_timeout, and other waits keep their own timeout.
The tool call tests now state each behavior as a table case: what came before the request, and what the request answers. The integration test covers each acting SDK method the same way, checking that a repeat does not act again, instead of re-testing the table's cases. The test-only export is gone: the cancel-during-run test detects the recorded cancel from the refusal a repeat gets.
stop_at_start_timeout read like a timeout for starting a process. The option makes an output wait also end at the execute timeout, so it is now TimeoutFromExecute (timeout_from_execute=true). Background processes are only read by regular output waits, so the agent now rejects a start that sets both background and timeout_ms. The wait behavior is now tested through the real agent with a timeout that has already passed, so the outcome does not depend on timing, and the test also catches the SDK and agent disagreeing on the parameter. The fake-clock wait test and the separate kill test are gone: the plain-wait case covers the kill.
TestAgent_ToolCall covers running once, repeats, and cancels through the real agent, so the table's copies of those cases are gone. The cancel-during-run test now releases the run when the cancel reaches the table instead of polling, and asserts only what holds whichever of the two finishes first.
Removing the time check from timed_out, or sending the tool call ID from ProcessOutput, passed every test. The execute timeout test now has a case with a timeout that cannot pass during the test, and both agent tests send the tool call context on every call, as chatd does, so a method that must not send the header gets a saved response. The cross-chat test read private fields, and its check that another chat's cancel did not kill the process read Running right after the kill, which cannot see a wrong kill. It now runs through the real agent with two chats and checks Canceled, which a cancel sets before it answers. Only the repeated start from the same chat stays a unit test, since the real agent's table answers repeats first. Each execute timeout case now cancels its process on cleanup, so the agent no longer waits for "sleep 300" when it closes. The tool call ID log field is asserted in one middleware test instead of two, and the cancel-during-run comment now says the run is released when the cancel request reaches the cancel route.
Requests without a chat ID could list, read, and signal every chat's processes, and a chat could read processes started without one. Keying the map by chat ID and process ID gives each chat, and requests without a chat ID (the zero UUID, as in the tool call middleware), only their own processes, and replaces the scattered ownership checks. Two chats that start the same tool call ID now get separate processes instead of an error for the second. The tool call middleware already serializes starts per chat ID and tool call ID, so start needs no new locking.
The old comment said a retry after the age runs the tool call again, but a record is only deleted when a later record is added. The comment now says that, and what a retry and a cancel get after deletion.
A retried execute gets the saved start response and then reads the process output, and the tool call record lives for an hour. With a 5-minute reap age the process could be gone while the record still answered retries with its ID. An hour after exit is at least as long as the record lives.
chatd commits tool results once per batch and resends every call in the batch on each retry. A fast call in a batch with an execute that runs for over an hour lost its record to the first unrelated add, so the next retry ran it again. Count the hour from the last request or cancel for a record instead of from when it was added.
96c13dc to
2a7852f
Compare
|
@codex review |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
Codex Review: Didn't find any major issues. 👍 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
agenttoolcall cancels execute, edit_files, and write_file calls, so a hook named kill described only the execute case. The process API keeps killing the process; only the names change.
a820e96 to
dd9f955
Compare
Keep only comments that state a contract, invariant, or reason the code does not show, and write them as Go doc comments.
dd9f955 to
bbbea78
Compare
The option belongs to the agent process API, so name it after the StartProcess timeout it applies instead of chatd's execute tool. The query parameter becomes timeout_from_start_process.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
Successfully merging this pull request may close these issues.
There was an error while loading. Please reload this page.