Files
coder/coderd/x/chatd/toolinput.go
T
Jaayden HalkoandHugo Dutka 64d2d8a108 feat(coderd/x/chatd): record live tool execution stamps in the message part buffer (#28361)
**Stack**: #28359 → #28360 → **#28361 (this PR)** → #28362

During local tool execution, buffer episodes now record when each
tool-call occurrence actually starts and finishes, via
`RecordToolStart`/`RecordToolCompletion`/`ToolCompletions` on the
message part buffer. Occurrences are keyed by unresolved-call position,
so gaps left by rejected calls and duplicate tool-call IDs stay
distinct: `partitionAmbiguousToolCalls` additionally returns the allowed
calls' input positions, and generation's `chatloop.ToolBillingRecorder`
implementation translates dispatch-order stamps back through them.

Mechanism only: nothing reads the stamps yet. The final PR in the stack
uses them to bill interrupted tool batches on a dedicated usage record
committed alongside their synthesized cancellation rows.

Third of a four-PR stack splitting #28211. Stacked on #28360. Refs
CODAGT-928
(https://linear.app/codercom/issue/CODAGT-928/track-local-tool-execution-for-agent-runtime).

---------

Co-authored-by: Hugo Dutka <hugo@coder.com>
2026-08-25 16:57:27 +07:00

101 lines
3.9 KiB
Go

package chatd
import (
"encoding/json"
"charm.land/fantasy"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/x/chatd/chathooks"
"github.com/coder/coder/v2/coderd/x/chatd/toolschema"
)
// partitionAmbiguousToolCalls separates the calls a consumer must not be asked
// to decide from the rest, returning synthetic error results for them. Callers
// reject before pre_tool_use so a hook consumer is never asked to authorize
// bytes whose meaning depends on which reader resolves them, and so input that
// cannot be carried in a hook payload fails as a retryable tool error instead
// of a dispatch failure. allowedIndexes maps allowed calls back to the input
// order without relying on duplicate-prone IDs.
func partitionAmbiguousToolCalls(
prepared generationPrepared,
toolCalls []fantasy.ToolCallContent,
) (allowed []fantasy.ToolCallContent, allowedIndexes []int, rejected []fantasy.ToolResultContent) {
for i, toolCall := range toolCalls {
if !json.Valid([]byte(toolCall.Input)) {
rejected = append(rejected, malformedToolResult(toolCall))
continue
}
if err := validateBuiltinToolInput(prepared, toolCall.ToolName, []byte(toolCall.Input)); err != nil {
rejected = append(rejected, ambiguousToolResult(toolCall, err))
continue
}
allowed = append(allowed, toolCall)
allowedIndexes = append(allowedIndexes, i)
}
return allowed, allowedIndexes, rejected
}
// validateOverriddenToolInputs rechecks the inputs a pre_tool_use consumer
// replaced. The model cannot fix an ambiguous override, so the turn fails
// closed instead of executing it.
func validateOverriddenToolInputs(prepared generationPrepared, preflight chathooks.PreToolUseExecutionResult) error {
for _, toolCall := range preflight.Allowed {
if _, overridden := preflight.Overrides[toolCall.ToolCallID]; !overridden {
continue
}
if err := validateBuiltinToolInput(prepared, toolCall.ToolName, []byte(toolCall.Input)); err != nil {
return xerrors.Errorf("hook input override for tool %s: %w", toolCall.ToolName, err)
}
}
return nil
}
// validateBuiltinToolInput only guards builtin tools, whose input coderd
// decodes itself. Dynamic calls are executed by the client and MCP calls by
// their own server, and a dynamic tool cannot shadow a builtin name.
func validateBuiltinToolInput(prepared generationPrepared, toolName string, input []byte) error {
// Execution resolves a deprecated alias to its canonical tool, so
// skipping that here would let the old name bypass validation.
if canonical, aliased := subagentToolNameAliases[toolName]; aliased {
toolName = canonical
}
if !prepared.BuiltinToolNames[toolName] {
return nil
}
for _, tool := range prepared.Tools {
info := tool.Info()
if info.Name != toolName {
continue
}
return toolschema.ValidateUnambiguous(info.Parameters, input)
}
return nil
}
// malformedToolResult reports input the tool decoder would reject anyway. It
// is produced here because a hook payload carries the input as JSON, so
// invalid bytes would otherwise surface as a dispatch failure and end the
// turn instead of letting the model correct the call.
func malformedToolResult(toolCall fantasy.ToolCallContent) fantasy.ToolResultContent {
return fantasy.ToolResultContent{
ToolCallID: toolCall.ToolCallID,
ToolName: toolCall.ToolName,
Result: fantasy.ToolResultOutputContentError{
Error: xerrors.New("This tool call was not executed because its input is not valid JSON. Retry with a well-formed JSON object matching the tool schema."),
},
}
}
func ambiguousToolResult(toolCall fantasy.ToolCallContent, err error) fantasy.ToolResultContent {
message := "This tool call was not executed because its input is ambiguous: " + err.Error() +
". Retry with the exact property names from the tool schema, each key used once."
return fantasy.ToolResultContent{
ToolCallID: toolCall.ToolCallID,
ToolName: toolCall.ToolName,
Result: fantasy.ToolResultOutputContentError{
Error: xerrors.New(message),
},
}
}