mirror of
https://github.com/coder/coder.git
synced 2026-06-03 21:18:24 +00:00
b969d66978
Adds client-executed dynamic tools to the chat API. Dynamic tools are
declared by the client at chat creation time, presented to the LLM
alongside built-in tools, but executed by the client rather than chatd.
This enables external systems (Slack bots, IDE extensions, Discord bots,
CI/CD integrations) to plug custom tools into the LLM chat loop without
modifying chatd's built-in tool set.
Modeled after OpenAI's Assistants API: the chat pauses with
`requires_action` status when the LLM calls a dynamic tool, the client
POSTs results back via `POST /chats/{id}/tool-results`, and the chat
resumes.
See [this example](https://github.com/coder/coder-slackbot-poc) as a
reference for how this is used. It's highly-configurable, which would
enable creating chats from webhooks, periodically polling, or running as
a Slackbot.
<details>
<summary>Design context</summary>
### Architecture
The chatloop **exits** when it encounters dynamic tools and
**re-enters** when results arrive. No blocking channels, no pubsub for
tool results, no in-memory registry. The DB is the only coordination
mechanism.
```
Phase 1 (chatloop):
LLM response → execute built-in tools only →
Persist(assistant + built-in results) →
status = requires_action → chatloop exits
Phase 2 (POST /tool-results):
Persist(dynamic tool results) →
status = pending → wakeCh → chatloop re-enters
```
### Validation (POST /tool-results)
1. Chat status must be `requires_action` (409 if not)
2. Read chat's `dynamic_tools` → set of dynamic tool names
3. Read last assistant message → extract tool-call parts matching
dynamic tool names
4. Submitted tool_call_ids must match exactly (400 for missing/extra)
5. Persist tool-result message parts, set status to `pending`, signal
wake
### Idempotency
Tool call IDs scoped per LLM step. State machine (`requires_action` →
`pending`) is the guard. First POST wins, subsequent get 409.
### Mixed tool calls
When the LLM calls both built-in and dynamic tools in one step, built-in
tools execute immediately. Their results are persisted in phase 1.
Dynamic tool results arrive via POST in phase 2. The LLM sees all
results when the chatloop resumes.
</details>
> 🤖 Generated by Coder Agents
92 lines
2.7 KiB
Go
92 lines
2.7 KiB
Go
package chatd
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
|
|
"charm.land/fantasy"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
// dynamicTool wraps a codersdk.DynamicTool as a fantasy.AgentTool.
|
|
// These tools are presented to the LLM but never executed by the
|
|
// chatloop — when the LLM calls one, the chatloop exits with
|
|
// requires_action status and the client handles execution.
|
|
// The Run method should never be called; it returns an error if
|
|
// it is, as a safety net.
|
|
type dynamicTool struct {
|
|
name string
|
|
description string
|
|
parameters map[string]any
|
|
required []string
|
|
opts fantasy.ProviderOptions
|
|
}
|
|
|
|
// dynamicToolsFromSDK converts codersdk.DynamicTool definitions
|
|
// into fantasy.AgentTool implementations for inclusion in the LLM
|
|
// tool list.
|
|
func dynamicToolsFromSDK(logger slog.Logger, tools []codersdk.DynamicTool) []fantasy.AgentTool {
|
|
if len(tools) == 0 {
|
|
return nil
|
|
}
|
|
result := make([]fantasy.AgentTool, 0, len(tools))
|
|
for _, t := range tools {
|
|
dt := &dynamicTool{
|
|
name: t.Name,
|
|
description: t.Description,
|
|
}
|
|
// InputSchema is a full JSON Schema object stored as
|
|
// json.RawMessage. Extract the "properties" and
|
|
// "required" fields that fantasy.ToolInfo expects.
|
|
if len(t.InputSchema) > 0 {
|
|
var schema struct {
|
|
Properties map[string]any `json:"properties"`
|
|
Required []string `json:"required"`
|
|
}
|
|
if err := json.Unmarshal(t.InputSchema, &schema); err != nil {
|
|
// Defensive: present the tool with no parameter
|
|
// constraints rather than failing. The LLM may
|
|
// hallucinate argument shapes, but the tool will
|
|
// still appear in the tool list.
|
|
logger.Warn(context.Background(), "failed to parse dynamic tool input schema",
|
|
slog.F("tool_name", t.Name),
|
|
slog.Error(err))
|
|
} else {
|
|
dt.parameters = schema.Properties
|
|
dt.required = schema.Required
|
|
}
|
|
}
|
|
result = append(result, dt)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (t *dynamicTool) Info() fantasy.ToolInfo {
|
|
return fantasy.ToolInfo{
|
|
Name: t.name,
|
|
Description: t.description,
|
|
Parameters: t.parameters,
|
|
Required: t.required,
|
|
}
|
|
}
|
|
|
|
func (*dynamicTool) Run(_ context.Context, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
|
// Dynamic tools are never executed by the chatloop. If this
|
|
// method is called, it indicates a bug in the chatloop's
|
|
// dynamic tool detection logic.
|
|
return fantasy.NewTextErrorResponse(
|
|
"dynamic tool called in chatloop — this is a bug; " +
|
|
"dynamic tools should be handled by the client",
|
|
), nil
|
|
}
|
|
|
|
func (t *dynamicTool) ProviderOptions() fantasy.ProviderOptions {
|
|
return t.opts
|
|
}
|
|
|
|
func (t *dynamicTool) SetProviderOptions(opts fantasy.ProviderOptions) {
|
|
t.opts = opts
|
|
}
|