mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add dynamic tools support for chat API (#24036)
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
This commit is contained in:
@@ -38,13 +38,23 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInterrupted = xerrors.New("chat interrupted")
|
||||
ErrInterrupted = xerrors.New("chat interrupted")
|
||||
ErrDynamicToolCall = xerrors.New("dynamic tool call")
|
||||
|
||||
errStartupTimeout = xerrors.New(
|
||||
"chat response did not start before the startup timeout",
|
||||
)
|
||||
)
|
||||
|
||||
// PendingToolCall describes a tool call that targets a dynamic
|
||||
// tool. These calls are not executed by the chatloop; instead
|
||||
// they are persisted so the caller can fulfill them externally.
|
||||
type PendingToolCall struct {
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
Args string
|
||||
}
|
||||
|
||||
// PersistedStep contains the full content of a completed or
|
||||
// interrupted agent step. Content includes both assistant blocks
|
||||
// (text, reasoning, tool calls) and tool result blocks. The
|
||||
@@ -60,6 +70,11 @@ type PersistedStep struct {
|
||||
// Zero indicates the duration was not measured (e.g.
|
||||
// interrupted steps).
|
||||
Runtime time.Duration
|
||||
// PendingDynamicToolCalls lists tool calls that target
|
||||
// dynamic tools. When non-empty the chatloop exits with
|
||||
// ErrDynamicToolCall so the caller can execute them
|
||||
// externally and resume the loop.
|
||||
PendingDynamicToolCalls []PendingToolCall
|
||||
}
|
||||
|
||||
// RunOptions configures a single streaming chat loop run.
|
||||
@@ -77,6 +92,12 @@ type RunOptions struct {
|
||||
ActiveTools []string
|
||||
ContextLimitFallback int64
|
||||
|
||||
// DynamicToolNames lists tool names that are handled
|
||||
// externally. When the model invokes one of these tools
|
||||
// the chatloop persists partial results and exits with
|
||||
// ErrDynamicToolCall instead of executing the tool.
|
||||
DynamicToolNames map[string]bool
|
||||
|
||||
// ModelConfig holds per-call LLM parameters (temperature,
|
||||
// max tokens, etc.) read from the chat model configuration.
|
||||
ModelConfig codersdk.ChatModelCallConfig
|
||||
@@ -385,7 +406,22 @@ func Run(ctx context.Context, opts RunOptions) error {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
toolResults = executeTools(ctx, opts.Tools, opts.ProviderTools, result.toolCalls, func(tr fantasy.ToolResultContent) {
|
||||
// Partition tool calls into built-in and dynamic.
|
||||
var builtinCalls, dynamicCalls []fantasy.ToolCallContent
|
||||
if len(opts.DynamicToolNames) > 0 {
|
||||
for _, tc := range result.toolCalls {
|
||||
if opts.DynamicToolNames[tc.ToolName] {
|
||||
dynamicCalls = append(dynamicCalls, tc)
|
||||
} else {
|
||||
builtinCalls = append(builtinCalls, tc)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
builtinCalls = result.toolCalls
|
||||
}
|
||||
|
||||
// Execute only built-in tools.
|
||||
toolResults = executeTools(ctx, opts.Tools, opts.ProviderTools, builtinCalls, func(tr fantasy.ToolResultContent) {
|
||||
publishMessagePart(
|
||||
codersdk.ChatMessageRoleTool,
|
||||
chatprompt.PartFromContent(tr),
|
||||
@@ -395,6 +431,47 @@ func Run(ctx context.Context, opts RunOptions) error {
|
||||
result.content = append(result.content, tr)
|
||||
}
|
||||
|
||||
// If dynamic tools were called, persist what we
|
||||
// have (assistant + built-in results) and exit so
|
||||
// the caller can execute them externally.
|
||||
if len(dynamicCalls) > 0 {
|
||||
pending := make([]PendingToolCall, 0, len(dynamicCalls))
|
||||
for _, dc := range dynamicCalls {
|
||||
pending = append(pending, PendingToolCall{
|
||||
ToolCallID: dc.ToolCallID,
|
||||
ToolName: dc.ToolName,
|
||||
Args: dc.Input,
|
||||
})
|
||||
}
|
||||
|
||||
contextLimit := extractContextLimit(result.providerMetadata)
|
||||
if !contextLimit.Valid && opts.ContextLimitFallback > 0 {
|
||||
contextLimit = sql.NullInt64{
|
||||
Int64: opts.ContextLimitFallback,
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
if err := opts.PersistStep(ctx, PersistedStep{
|
||||
Content: result.content,
|
||||
Usage: result.usage,
|
||||
ContextLimit: contextLimit,
|
||||
ProviderResponseID: extractOpenAIResponseIDIfStored(opts.ProviderOptions, result.providerMetadata),
|
||||
Runtime: time.Since(stepStart),
|
||||
PendingDynamicToolCalls: pending,
|
||||
}); err != nil {
|
||||
if errors.Is(err, ErrInterrupted) {
|
||||
persistInterruptedStep(ctx, opts, &result)
|
||||
return ErrInterrupted
|
||||
}
|
||||
return xerrors.Errorf("persist step: %w", err)
|
||||
}
|
||||
|
||||
tryCompactOnExit(ctx, opts, result.usage, result.providerMetadata)
|
||||
|
||||
return ErrDynamicToolCall
|
||||
}
|
||||
|
||||
// Check for interruption after tool execution.
|
||||
// Tools that were canceled mid-flight produce error
|
||||
// results via ctx cancellation. Persist the full
|
||||
@@ -1088,6 +1165,38 @@ func persistInterruptedStep(
|
||||
}
|
||||
}
|
||||
|
||||
// tryCompactOnExit runs compaction when the chatloop is about
|
||||
// to exit early (e.g. via ErrDynamicToolCall). The normal
|
||||
// inline and post-run compaction paths are unreachable in
|
||||
// early-exit scenarios, so this ensures the context window
|
||||
// doesn't grow unbounded.
|
||||
func tryCompactOnExit(
|
||||
ctx context.Context,
|
||||
opts RunOptions,
|
||||
usage fantasy.Usage,
|
||||
metadata fantasy.ProviderMetadata,
|
||||
) {
|
||||
if opts.Compaction == nil || opts.ReloadMessages == nil {
|
||||
return
|
||||
}
|
||||
reloaded, err := opts.ReloadMessages(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, compactErr := tryCompact(
|
||||
ctx,
|
||||
opts.Model,
|
||||
opts.Compaction,
|
||||
opts.ContextLimitFallback,
|
||||
usage,
|
||||
metadata,
|
||||
reloaded,
|
||||
)
|
||||
if compactErr != nil && opts.Compaction.OnError != nil {
|
||||
opts.Compaction.OnError(compactErr)
|
||||
}
|
||||
}
|
||||
|
||||
// buildToolDefinitions converts AgentTool definitions into the
|
||||
// fantasy.Tool slice expected by fantasy.Call. When activeTools
|
||||
// is non-empty, only function tools whose name appears in the
|
||||
|
||||
@@ -713,4 +713,76 @@ func TestRun_Compaction(t *testing.T) {
|
||||
}
|
||||
require.True(t, hasUser, "re-entry prompt must contain a user message (the compaction summary)")
|
||||
})
|
||||
|
||||
t.Run("TriggersOnDynamicToolExit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var persistCompactionCalls int
|
||||
const summaryText = "compaction summary for dynamic tool exit"
|
||||
|
||||
// The LLM calls a dynamic tool. Usage is above the
|
||||
// compaction threshold so compaction should fire even
|
||||
// though the chatloop exits via ErrDynamicToolCall.
|
||||
model := &loopTestModel{
|
||||
provider: "fake",
|
||||
streamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeToolInputStart, ID: "tc-1", ToolCallName: "my_dynamic_tool"},
|
||||
{Type: fantasy.StreamPartTypeToolInputDelta, ID: "tc-1", Delta: `{"query": "test"}`},
|
||||
{Type: fantasy.StreamPartTypeToolInputEnd, ID: "tc-1"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeToolCall,
|
||||
ID: "tc-1",
|
||||
ToolCallName: "my_dynamic_tool",
|
||||
ToolCallInput: `{"query": "test"}`,
|
||||
},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeFinish,
|
||||
FinishReason: fantasy.FinishReasonToolCalls,
|
||||
Usage: fantasy.Usage{
|
||||
InputTokens: 80,
|
||||
TotalTokens: 85,
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
},
|
||||
generateFn: func(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) {
|
||||
return &fantasy.Response{
|
||||
Content: []fantasy.Content{
|
||||
fantasy.TextContent{Text: summaryText},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
},
|
||||
MaxSteps: 5,
|
||||
DynamicToolNames: map[string]bool{"my_dynamic_tool": true},
|
||||
PersistStep: func(_ context.Context, _ PersistedStep) error {
|
||||
return nil
|
||||
},
|
||||
ContextLimitFallback: 100,
|
||||
Compaction: &CompactionOptions{
|
||||
ThresholdPercent: 70,
|
||||
SummaryPrompt: "summarize now",
|
||||
Persist: func(_ context.Context, result CompactionResult) error {
|
||||
persistCompactionCalls++
|
||||
require.Contains(t, result.SystemSummary, summaryText)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
ReloadMessages: func(_ context.Context) ([]fantasy.Message, error) {
|
||||
return []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
require.ErrorIs(t, err, ErrDynamicToolCall)
|
||||
require.Equal(t, 1, persistCompactionCalls,
|
||||
"compaction must fire before dynamic tool exit")
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user