Conversation
A tool call whose input does not parse was persisted with no args, so
the malformed text was lost and failure shapes could not be analysed
from the database. The tool-call part now carries it as invalid_args,
NUL-encoded like other free-form fields. It is internal like
provider_metadata: stripped before API responses and absent from the
TypeScript types. Prompt replay still reads only args, so the model
sees the same "{}" as before.
This was referenced Sep 26, 2026
The field comment said invalid_args is stripped before API responses, but only StripInternal clears it and the chat stream does not call that, so it now names StripInternal. "Non-empty" becomes "not blank" because whitespace-only input is dropped too. The safeToolCallArgs comment no longer repeats what the field comment already says.
The comment said the function "ensures" args are valid JSON, which described no result. It now says what each input returns.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Tool calls whose input is not valid JSON were stored without args, so the malformed text was lost. The tool-call part now keeps it as
invalid_args, so failure shapes can be analysed from the database. The rest of this stack needs that data to evaluate the newedit_filesschema.invalid_argsis internal likeprovider_metadata: stripped from REST responses and absent from the TypeScript types. The apidoc still lists it because swag ignorestypescript:"-", the same as the other internal fields. Prompt replay reads onlyargs, so the model still sees{}. Calls cut off mid-stream are still dropped; a complete call buffered before an interrupt keeps the field.Stack, merge in order:
edit_filesargsedit_filesgrouping helpersedit_filesto a flat edits listedit_filesoutcomes explicitlyedit_filesper fileImplementation plan
edit_files: flat schema, clear results, file-level partial application
Scope of this stack: parts 1 to 5. Parts 6 (previous-call guard) and 7 (re-analysis) are not being implemented. Without the guard no file can have the
already_appliedstatus, so thealready_appliedentries, counts and UI note below are out of scope.Direction (for approval)
Outcome. Fewer wasted model turns on
edit_files: fewer malformed calls, no valid work thrown away, and results that say exactly what happened.Observable end state
editslist; each edit carries its ownpath.edits[i]), the reason, and what to resend.filesform of the same edits, and execution uses exactly what the hook approved.Recommended direction. Replace the nested
files[].edits[]schema with a flat list (schema B), then build clearer results, file-level partial application, no-op rejection and a previous-call guard on top, in that order. All changes are in coderd; the workspace agent API stays as it is until the changes are validated.Why. Evidence from dogfood (75,070 calls, 8,914 failures, 2026-03-16 to 2026-09-26; analysis in
/home/coder/efa):filessent as a JSON string, 81% from Claude families; none parsed as-is. The nested array is the suspected cause.Shape of the work (in order)
filesargs keep rendering).Ruled out
{path, edits}hybrid or top-level first edit (proposal A): two ways to express an edit; unclear what applies when.\"in tests.filesshape after B: one contract for hooks and UI; after the old_text/new_text rename only 62 calls used old keys.editsraw to hooks: a policy readingedits[].pathwould see nothing.Decisions made
editssent as a JSON string is accepted only if it parses as-is; storage, UI, hooks and execution see the decoded array.{"files":[{"path","edits":[...]}]}and convertsinput_overrideback. Moving hooks to B is a separate, explicit break after B is validated (chatd is experimental, so this is allowed but not needed yet).filesshape is rejected with an error showing the B shape.is_errorfalse,status: "partial").edit_filescall in the chat; exact match; no trimming.Assumptions, constraints, tradeoffs
coderd/x/chatdis experimental; the hook contract could change, but this plan keeps it unchanged until B is validated.Risks
editsare broken like before. Evidence: the safety net only accepts strings that parse as-is, and none of the 2,681 historical stringifiedfilesvalues did; the top-level array is what gets stringified (files2,680 times vs the nestededitsarray 3 times), and B still has one. Trigger:claude-sonnet-5malformed rate stays near today's 27% two weeks after part 2. Response: analyze B-era failure shapes with the existing tooling; add repair rules only for observed shapes.User stories
Model
{"edits":[{"path","old_text","new_text"}]}with no nested groups.editsarrives as a JSON-encoded string that is otherwise valid, the call still works.filesshape, the error shows the new shape.User
Admin / hook author
pre_tool_usepolicies keep working unchanged when the model-facing schema changes.Contracts
C1. Tool input (schema B)
{"type":"object","required":["edits"],"properties":{ "edits":{"type":"array","minItems":1,"items":{ "type":"object","required":["path","old_text","new_text"],"properties":{ "path":{"type":"string","description":"Absolute path of the file to edit."}, "old_text":{"type":"string","description":"Exact text to replace. Must match exactly one location unless replace_all is true. Must differ from new_text."}, "new_text":{"type":"string","description":"Replacement text."}, "replace_all":{"type":"boolean","description":"Replace every match of old_text."}}}}}}Example:
{"edits":[{"path":"/repo/a.go","old_text":"x := 1","new_text":"x := 2"},{"path":"/repo/b.go","old_text":"foo()","new_text":"bar()"}]}C2. Input acceptance
editsis a JSON string that parses as-is into an array of objects: decoded where the tool call arrives (before hooks and persistence); everything downstream sees the array. Anything else in a string: whole call rejected.fileskey: whole call rejected, error shows the C1 example.old_text/new_textare accepted inside edits (nosearch/replace).edits[i].pathties its error to that file (for example relative path, empty old_text). An edit withoutpath, or malformed input, rejects the whole call. Plan-turn path restriction stays whole-call.C3. Application semantics
path(as sent), keeping their order; each file's edits apply in order.old_text == new_textis an error for its file.edit_filescall in the chat (most recent call before this one, with status applied or partial) defines the applied edits:(path, old_text, new_text, replace_all)of its applied files. Each edit in the new call that exactly matches one of them is skipped and reported as already applied; the rest of that file proceeds normally. Paths compare as sent by the model, not symlink-resolved.C4. Tool result (what the model reads)
All applied (
is_error: false):{"status":"applied","message":"Applied edits to 2 files.", "files":[{"path":"/repo/a.go","status":"applied","diff":"..."}, {"path":"/repo/b.go","status":"applied","diff":"..."}]}Partial (
is_error: false), rejected files first:{"status":"partial", "message":"Applied 1 file. 1 file was already applied by your previous call. /repo/c.go was not applied (none of edits[3], edits[4] were applied): fix and resend only the edits for /repo/c.go.", "files":[ {"path":"/repo/c.go","status":"rejected","edits":[3,4], "error":"old_text matches 3 occurrences (expected exactly 1). Include more surrounding context to make the match unique, or set replace_all to true"}, {"path":"/repo/a.go","status":"applied","diff":"..."}, {"path":"/repo/d.go","status":"already_applied","message":"edits[5] matched your previous call and was not applied again."}]}Nothing applied (
is_error: true), text. Part 3 (all-or-nothing, one agent request):From part 4 (per-file requests, every file failed):
Whole-call rejections (malformed input, old shape, missing path, plan restriction, infrastructure) use the same
is_error: truetext form, starting with what to change.Counting: the message counts applied, already-applied and rejected files separately; an already-applied file is not counted as applied.
statusisappliedwhen no file is rejected (including when some or all files are already applied),partialwhen at least one file is applied or already applied and at least one is rejected, and an error when no file is applied or already applied.C5. Workspace agent API (
POST /api/v0/edit-files)Unchanged. Part 3 sends one request with all files, as today; a 400 means nothing was written (batches are atomic since 2026-03-24). Part 4 sends one request per file (edits grouped by path, in order) and derives each file's status from its own response.
C6. Hooks
pre_tool_use.tool_inputforedit_fileskeeps today's shape:{"files":[{"path":"...","edits":[{"old_text":"...","new_text":"...","replace_all":false}]}]}, built from B by grouping edits by path in order (a string-encodededitsis decoded first).input_overrideis accepted in the same shape and flattened back to B before validation and execution.docs/admin/setup/chat-lifecycle-hooks.md: note thatedit_filestool input is presented in this grouped form rather than the model's exact bytes.C7. Storage and UI
filesargs in existing chats.status: applied shows the server diff; rejected shows its error and never a synthetic diff; already_applied shows a note; header reflects partial ("Edited 1 of 3 files", counting only applied files).C8. Observability
Implementation detail
Parts, surfaces, dependencies
utils.ts/Tool.tsx/EditFilesTool.tsxparse and render B args alongside oldfilesargs.coderd/x/chatd/chathooks(or next totoolinput.go): B to grouped form, grouped override to B. Unit tests only.coderd/x/chatd/chattool/editfiles.go(args, validation, grouping into the agent'sFileEdits, index mapping, tool description), chatloop tool-input decoding at stream arrival (prototype:RepairToolInputinterface, worktreemathias/edit-files-input-repair), wiring 2b intotoolinput.go/generation.go, hooks docs note.editfiles.gorendering (C4 applied and nothing-applied forms), UI. Depends on 2.editfiles.go(one agent request per file, per-file statuses, C4 partial form), UI statuses. Depends on 3.editfiles.govalidation (coderd side, before the agent call). Depends on 4.generation_preparer.go(find the previousedit_filescall and C4 result in the prompt rows loaded per generation; pass applied edits intoEditFilesOptions),editfiles.go. Depends on 4 (needs per-file statuses in stored results)./home/coder/efa(extract.py and shape classifier need B awareness and the raw-input field). After 2 and after 4.References
/home/coder/efa/SUMMARY.md,out/{errors,retries,models,safetynet,noop_partial,guard,guard_prev}/REPORT.md.docs/admin/setup/chat-lifecycle-hooks.md(lines 69-82).agent/agentfiles/files.go.Verification
editsis stored as an array;pre_tool_usereceives the grouped form; aninput_overridein grouped form executes as approved.statusfollows the C4 counting rule.claude-sonnet-527.3% of calls), collateral withheld entries (1,910), not-found after identical re-send, no-op follow-up rework (73% vs 51%).Invariants
input_overridetheedits[i]indexes in results follow the flattened override, not the model's original call.