mirror of
https://github.com/coder/coder.git
synced 2026-11-01 04:24:56 +08:00
**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>
101 lines
3.9 KiB
Go
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),
|
|
},
|
|
}
|
|
}
|