mirror of
https://github.com/coder/coder.git
synced 2026-10-31 02:43:21 +08:00
**Stack**: #28359 → **#28360 (this PR)** → #28361 → #28362 ## Summary Completed local tool batches now count toward Coder Agent runtime (`chat_messages.runtime_ms`, the source of truth for `hb_agent_runtime_v1`), excluding sub-agent orchestration tools to avoid double counting, and without multiplying runtime for parallel tool calls. Second of a four-PR stack splitting #28211. Stacked on #28359. Refs CODAGT-928 (https://linear.app/codercom/issue/CODAGT-928/track-local-tool-execution-for-agent-runtime). ## Problem Agent runtime previously measured only model invocation wall clock (stream open to fully consumed). Time spent executing local tools between steps, including file operations, terminal commands, workspace provisioning, and MCP tools, was deliberately excluded, which undercounts the product definition of "actively processing a task". Naive inclusion has two hazards: a batch of parallel tool calls would bill N windows for one wall-clock wait, and `wait_agent` would re-bill child agents that already bill their own model and tool time. ## Fix Each completed local tool batch bills one window: the union of the billed tools' execution intervals, persisted as `runtime_ms` on a dedicated usage record appended after the batch's tool-result rows. The record is a tool-role message with `visibility='model'`, so it never reaches the API, SSE, or clients, and prompt replay drops it because its single internal `tool-batch-usage` part converts to no provider content. Its content carries an audit payload (`billed_ms`, `billed_calls`) so the billed window is inspectable after the fact; real tool-result rows never carry batch-level runtime. Concurrent calls all start at batch start, so 5 parallel 10s reads bill 10s, not 50s; serial calls (`SerialToolCalls`) count only from their own launch, so unbilled waits before them do not count. Sub-agent orchestration tools (`spawn_agent`, `wait_agent`, `message_agent`, `interrupt_agent`, `list_agents`, `list_subagent_models`, plus the deprecated `close_agent` alias) never extend the window: every chat, including children, applies the same rules to its own runtime, so a parent's `wait_agent` window would double count. A lone `wait_agent` bills 0 and appends no usage record; `execute` 10s in parallel with `wait_agent` 60s bills 10s. A test pins the unbilled set to the registered sub-agent tool catalog so they cannot drift. `GetTotalChatMessageRuntimeMsInRange` already sums `runtime_ms` role-agnostically, so the usage record is picked up with no schema, query, or cron changes. Client-executed dynamic tools, external agents, parked/idle time, and retry backoff remain unbilled. Interrupted batches still bill only the model window; the rest of the stack adds partial-window billing via the same usage record. `ExecuteLocalToolsOptions` also gains an optional `ToolBillingRecorder` (`RecordStart`/`RecordComplete`) observing per-occurrence dispatch-order execution stamps. This PR wires no recorder; the next PR in the stack connects it to the message part buffer for interrupt billing. **NOTE**: Reported agent runtime (`hb_agent_runtime_v1`) increases from deploy forward. There is no backfill and no feature flag.
723 lines
22 KiB
Go
723 lines
22 KiB
Go
package chatloop_test
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"charm.land/fantasy"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chatloop"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
|
|
"github.com/coder/coder/v2/testutil"
|
|
"github.com/coder/quartz"
|
|
)
|
|
|
|
func TestGenerateAssistant_RecordsModelInvocationRuntime(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clock := quartz.NewMock(t)
|
|
model := &chattest.FakeModel{
|
|
ProviderName: "test-provider",
|
|
ModelName: "test-model",
|
|
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
|
return func(yield func(fantasy.StreamPart) bool) {
|
|
if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextStart, ID: "t"}) {
|
|
return
|
|
}
|
|
if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextDelta, ID: "t", Delta: "hello"}) {
|
|
return
|
|
}
|
|
if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextEnd, ID: "t"}) {
|
|
return
|
|
}
|
|
clock.Advance(1500 * time.Millisecond)
|
|
yield(fantasy.StreamPart{
|
|
Type: fantasy.StreamPartTypeFinish,
|
|
FinishReason: fantasy.FinishReasonStop,
|
|
})
|
|
}, nil
|
|
},
|
|
}
|
|
|
|
outcome, err := chatloop.GenerateAssistant(context.Background(), chatloop.GenerateAssistantOptions{
|
|
Model: model,
|
|
Clock: clock,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1500*time.Millisecond, outcome.Step.Runtime)
|
|
}
|
|
|
|
// The interrupt path bills the window OnModelStreamStart opens, so that
|
|
// hook must fire at the instant PersistedStep.Runtime starts measuring.
|
|
func TestGenerateAssistant_ModelStreamStartMatchesRuntimeWindow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clock := quartz.NewMock(t)
|
|
model := &chattest.FakeModel{
|
|
ProviderName: "test-provider",
|
|
ModelName: "test-model",
|
|
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
|
return func(yield func(fantasy.StreamPart) bool) {
|
|
if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextStart, ID: "t"}) {
|
|
return
|
|
}
|
|
if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextDelta, ID: "t", Delta: "hello"}) {
|
|
return
|
|
}
|
|
if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextEnd, ID: "t"}) {
|
|
return
|
|
}
|
|
clock.Advance(1500 * time.Millisecond)
|
|
yield(fantasy.StreamPart{
|
|
Type: fantasy.StreamPartTypeFinish,
|
|
FinishReason: fantasy.FinishReasonStop,
|
|
})
|
|
}, nil
|
|
},
|
|
}
|
|
|
|
var startedAt []time.Time
|
|
outcome, err := chatloop.GenerateAssistant(context.Background(), chatloop.GenerateAssistantOptions{
|
|
Model: model,
|
|
Clock: clock,
|
|
OnModelStreamStart: func() {
|
|
startedAt = append(startedAt, clock.Now())
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, startedAt, 1)
|
|
require.Equal(t, outcome.Step.Runtime, clock.Since(startedAt[0]))
|
|
}
|
|
|
|
func TestGenerateAssistant_ErroredStreamReturnsNoStep(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clock := quartz.NewMock(t)
|
|
model := &chattest.FakeModel{
|
|
ProviderName: "test-provider",
|
|
ModelName: "test-model",
|
|
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
|
return func(yield func(fantasy.StreamPart) bool) {
|
|
if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextStart, ID: "t"}) {
|
|
return
|
|
}
|
|
clock.Advance(1500 * time.Millisecond)
|
|
yield(fantasy.StreamPart{
|
|
Type: fantasy.StreamPartTypeError,
|
|
Error: xerrors.New("stream blew up"),
|
|
})
|
|
}, nil
|
|
},
|
|
}
|
|
|
|
outcome, err := chatloop.GenerateAssistant(context.Background(), chatloop.GenerateAssistantOptions{
|
|
Model: model,
|
|
Clock: clock,
|
|
})
|
|
require.Error(t, err)
|
|
require.Zero(t, outcome.Step.Runtime)
|
|
require.Empty(t, outcome.Step.Content)
|
|
}
|
|
|
|
func TestGenerateCompaction_RecordsRuntime(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clock := quartz.NewMock(t)
|
|
model := &chattest.FakeModel{
|
|
ProviderName: "test-provider",
|
|
ModelName: "test-model",
|
|
GenerateFn: func(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) {
|
|
clock.Advance(1500 * time.Millisecond)
|
|
return &fantasy.Response{
|
|
Content: []fantasy.Content{
|
|
fantasy.TextContent{Text: "summary"},
|
|
},
|
|
}, nil
|
|
},
|
|
}
|
|
|
|
var startedAt []time.Time
|
|
result, err := chatloop.GenerateCompaction(context.Background(), chatloop.GenerateCompactionOptions{
|
|
Model: model,
|
|
Messages: []fantasy.Message{{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}},
|
|
}},
|
|
ThresholdPercent: 70,
|
|
ContextLimit: 100,
|
|
StepUsage: fantasy.Usage{InputTokens: 90},
|
|
Clock: clock,
|
|
OnModelStreamStart: func() {
|
|
startedAt = append(startedAt, clock.Now())
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "summary", result.SummaryReport)
|
|
require.Equal(t, 1500*time.Millisecond, result.Runtime)
|
|
require.Len(t, startedAt, 1)
|
|
require.Equal(t, result.Runtime, clock.Since(startedAt[0]))
|
|
}
|
|
|
|
// executeToolBatch lets tests release trapped clock events in order, so
|
|
// goroutines cannot race clock advances.
|
|
func executeToolBatch(
|
|
t *testing.T,
|
|
clock *quartz.Mock,
|
|
opts chatloop.ExecuteLocalToolsOptions,
|
|
) <-chan chatloop.PersistedStep {
|
|
t.Helper()
|
|
opts.Clock = clock
|
|
resultCh := make(chan chatloop.PersistedStep, 1)
|
|
go func() {
|
|
outcome, err := chatloop.ExecuteLocalTools(context.Background(), opts)
|
|
assert.NoError(t, err)
|
|
resultCh <- outcome
|
|
}()
|
|
return resultCh
|
|
}
|
|
|
|
func blockingTool(name string, release <-chan struct{}, response fantasy.ToolResponse) fantasy.AgentTool {
|
|
return fantasy.NewAgentTool(
|
|
name,
|
|
"test tool that completes when released",
|
|
func(_ context.Context, _ struct{}, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
|
<-release
|
|
return response, nil
|
|
},
|
|
)
|
|
}
|
|
|
|
type serialTool struct {
|
|
fantasy.AgentTool
|
|
}
|
|
|
|
func (serialTool) SerialToolCalls() bool { return true }
|
|
|
|
func TestExecuteLocalTools_BatchWindowIsMaxNotSum(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
clock := quartz.NewMock(t)
|
|
trap := clock.Trap().Now()
|
|
defer trap.Close()
|
|
|
|
fastGo := make(chan struct{})
|
|
slowGo := make(chan struct{})
|
|
resultCh := executeToolBatch(t, clock, chatloop.ExecuteLocalToolsOptions{
|
|
Tools: []fantasy.AgentTool{
|
|
blockingTool("fast_tool", fastGo, fantasy.NewTextResponse("done")),
|
|
blockingTool("slow_tool", slowGo, fantasy.NewTextErrorResponse("blew up")),
|
|
},
|
|
ActiveTools: []string{"fast_tool", "slow_tool"},
|
|
ToolCalls: []fantasy.ToolCallContent{
|
|
{ToolCallID: "call-fast", ToolName: "fast_tool", Input: "{}"},
|
|
{ToolCallID: "call-slow", ToolName: "slow_tool", Input: "{}"},
|
|
},
|
|
})
|
|
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
clock.Advance(10 * time.Second)
|
|
close(fastGo)
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
clock.Advance(50 * time.Second)
|
|
close(slowGo)
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
|
|
outcome := testutil.RequireReceive(ctx, t, resultCh)
|
|
require.Equal(t, 60*time.Second, outcome.BatchRuntime)
|
|
require.Equal(t, 2, outcome.BatchBilledCalls)
|
|
}
|
|
|
|
func TestExecuteLocalTools_SimultaneousCompletionsBillOnce(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
clock := quartz.NewMock(t)
|
|
trap := clock.Trap().Now()
|
|
defer trap.Close()
|
|
|
|
release := make(chan struct{})
|
|
resultCh := executeToolBatch(t, clock, chatloop.ExecuteLocalToolsOptions{
|
|
Tools: []fantasy.AgentTool{
|
|
blockingTool("read_tool", release, fantasy.NewTextResponse("done")),
|
|
},
|
|
ActiveTools: []string{"read_tool"},
|
|
ToolCalls: []fantasy.ToolCallContent{
|
|
{ToolCallID: "call-1", ToolName: "read_tool", Input: "{}"},
|
|
{ToolCallID: "call-2", ToolName: "read_tool", Input: "{}"},
|
|
{ToolCallID: "call-3", ToolName: "read_tool", Input: "{}"},
|
|
},
|
|
})
|
|
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
clock.Advance(10 * time.Second)
|
|
close(release)
|
|
for range 3 {
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
}
|
|
|
|
outcome := testutil.RequireReceive(ctx, t, resultCh)
|
|
require.Equal(t, 10*time.Second, outcome.BatchRuntime)
|
|
}
|
|
|
|
func TestExecuteLocalTools_UnbilledToolNeverExtendsWindow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
clock := quartz.NewMock(t)
|
|
trap := clock.Trap().Now()
|
|
defer trap.Close()
|
|
|
|
executeGo := make(chan struct{})
|
|
waitGo := make(chan struct{})
|
|
resultCh := executeToolBatch(t, clock, chatloop.ExecuteLocalToolsOptions{
|
|
Tools: []fantasy.AgentTool{
|
|
blockingTool("execute", executeGo, fantasy.NewTextResponse("done")),
|
|
blockingTool("wait_agent", waitGo, fantasy.NewTextResponse("child report")),
|
|
},
|
|
ActiveTools: []string{"execute", "wait_agent"},
|
|
UnbilledToolNames: map[string]bool{"wait_agent": true},
|
|
ToolCalls: []fantasy.ToolCallContent{
|
|
{ToolCallID: "call-execute", ToolName: "execute", Input: "{}"},
|
|
{ToolCallID: "call-wait", ToolName: "wait_agent", Input: "{}"},
|
|
},
|
|
})
|
|
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
clock.Advance(10 * time.Second)
|
|
close(executeGo)
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
clock.Advance(50 * time.Second)
|
|
close(waitGo)
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
|
|
outcome := testutil.RequireReceive(ctx, t, resultCh)
|
|
require.Equal(t, 10*time.Second, outcome.BatchRuntime)
|
|
}
|
|
|
|
func TestExecuteLocalTools_UnbilledOnlyBatchBillsNothing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
clock := quartz.NewMock(t)
|
|
trap := clock.Trap().Now()
|
|
defer trap.Close()
|
|
|
|
waitGo := make(chan struct{})
|
|
resultCh := executeToolBatch(t, clock, chatloop.ExecuteLocalToolsOptions{
|
|
Tools: []fantasy.AgentTool{
|
|
blockingTool("wait_agent", waitGo, fantasy.NewTextResponse("child report")),
|
|
},
|
|
ActiveTools: []string{"wait_agent"},
|
|
UnbilledToolNames: map[string]bool{"wait_agent": true},
|
|
ToolCalls: []fantasy.ToolCallContent{
|
|
{ToolCallID: "call-wait", ToolName: "wait_agent", Input: "{}"},
|
|
},
|
|
})
|
|
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
clock.Advance(60 * time.Second)
|
|
close(waitGo)
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
|
|
outcome := testutil.RequireReceive(ctx, t, resultCh)
|
|
require.Zero(t, outcome.BatchRuntime)
|
|
require.Zero(t, outcome.BatchBilledCalls)
|
|
}
|
|
|
|
func TestExecuteLocalTools_AliasNamesClassifyAsCalled(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
clock := quartz.NewMock(t)
|
|
trap := clock.Trap().Now()
|
|
defer trap.Close()
|
|
|
|
executeGo := make(chan struct{})
|
|
legacyGo := make(chan struct{})
|
|
resultCh := executeToolBatch(t, clock, chatloop.ExecuteLocalToolsOptions{
|
|
Tools: []fantasy.AgentTool{
|
|
blockingTool("execute", executeGo, fantasy.NewTextResponse("done")),
|
|
blockingTool("interrupt_agent", legacyGo, fantasy.NewTextResponse("stopped")),
|
|
},
|
|
ActiveTools: []string{"execute", "interrupt_agent"},
|
|
ToolNameAliases: map[string]string{"close_agent": "interrupt_agent"},
|
|
UnbilledToolNames: map[string]bool{
|
|
"interrupt_agent": true,
|
|
"close_agent": true,
|
|
},
|
|
ToolCalls: []fantasy.ToolCallContent{
|
|
{ToolCallID: "call-execute", ToolName: "execute", Input: "{}"},
|
|
{ToolCallID: "call-legacy", ToolName: "close_agent", Input: "{}"},
|
|
},
|
|
})
|
|
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
clock.Advance(10 * time.Second)
|
|
close(executeGo)
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
clock.Advance(50 * time.Second)
|
|
close(legacyGo)
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
|
|
outcome := testutil.RequireReceive(ctx, t, resultCh)
|
|
require.Equal(t, 10*time.Second, outcome.BatchRuntime)
|
|
require.Equal(t, 1, outcome.BatchBilledCalls)
|
|
}
|
|
|
|
func TestExecuteLocalTools_DuplicateToolCallIDsKeepOccurrenceCompletions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
clock := quartz.NewMock(t)
|
|
trap := clock.Trap().Now()
|
|
defer trap.Close()
|
|
|
|
slowGo := make(chan struct{})
|
|
fastGo := make(chan struct{})
|
|
resultCh := executeToolBatch(t, clock, chatloop.ExecuteLocalToolsOptions{
|
|
Tools: []fantasy.AgentTool{
|
|
blockingTool("slow_tool", slowGo, fantasy.NewTextResponse("done")),
|
|
blockingTool("fast_tool", fastGo, fantasy.NewTextResponse("done")),
|
|
},
|
|
ActiveTools: []string{"slow_tool", "fast_tool"},
|
|
ToolCalls: []fantasy.ToolCallContent{
|
|
{ToolCallID: "call-dup", ToolName: "slow_tool", Input: "{}"},
|
|
{ToolCallID: "call-dup", ToolName: "fast_tool", Input: "{}"},
|
|
},
|
|
})
|
|
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
clock.Advance(10 * time.Second)
|
|
close(fastGo)
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
clock.Advance(50 * time.Second)
|
|
close(slowGo)
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
|
|
outcome := testutil.RequireReceive(ctx, t, resultCh)
|
|
require.Equal(t, 60*time.Second, outcome.BatchRuntime)
|
|
}
|
|
|
|
// recordingToolBillingRecorder is a test ToolBillingRecorder that
|
|
// counts start/complete calls and can publish live completions.
|
|
type recordingToolBillingRecorder struct {
|
|
starts int
|
|
completions int
|
|
completeCh chan recordedToolCompletion
|
|
}
|
|
|
|
type recordedToolCompletion struct {
|
|
dispatchIndex int
|
|
completedAt time.Time
|
|
}
|
|
|
|
func (r *recordingToolBillingRecorder) RecordStart(int, time.Time) {
|
|
r.starts++
|
|
}
|
|
|
|
func (r *recordingToolBillingRecorder) RecordComplete(dispatchIndex int, completedAt time.Time) {
|
|
r.completions++
|
|
if r.completeCh != nil {
|
|
r.completeCh <- recordedToolCompletion{
|
|
dispatchIndex: dispatchIndex,
|
|
completedAt: completedAt,
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExecuteLocalTools_BillingRecorderRecordsOnlyRuns(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("started call records a paired lifecycle", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
recorder := &recordingToolBillingRecorder{}
|
|
startedWhenToolRan := false
|
|
tool := fantasy.NewAgentTool(
|
|
"fast_tool",
|
|
"test tool",
|
|
func(context.Context, struct{}, fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
|
startedWhenToolRan = recorder.starts > 0
|
|
return fantasy.NewTextResponse("done"), nil
|
|
},
|
|
)
|
|
outcome, err := chatloop.ExecuteLocalTools(context.Background(), chatloop.ExecuteLocalToolsOptions{
|
|
Clock: quartz.NewMock(t),
|
|
Tools: []fantasy.AgentTool{tool},
|
|
ActiveTools: []string{"fast_tool"},
|
|
BillingRecorder: recorder,
|
|
ToolCalls: []fantasy.ToolCallContent{
|
|
{ToolCallID: "call-1", ToolName: "fast_tool", Input: "{}"},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, outcome.Content, 1)
|
|
require.Equal(t, 1, recorder.starts)
|
|
require.Equal(t, 1, recorder.completions)
|
|
require.True(t, startedWhenToolRan, "RecordStart must run before the tool runs")
|
|
})
|
|
|
|
t.Run("canceled context records no lifecycle", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
recorder := &recordingToolBillingRecorder{}
|
|
_, err := chatloop.ExecuteLocalTools(ctx, chatloop.ExecuteLocalToolsOptions{
|
|
Clock: quartz.NewMock(t),
|
|
BillingRecorder: recorder,
|
|
ToolCalls: []fantasy.ToolCallContent{
|
|
{ToolCallID: "call-1", ToolName: "fast_tool", Input: "{}"},
|
|
},
|
|
})
|
|
require.ErrorIs(t, err, context.Canceled)
|
|
require.Zero(t, recorder.starts)
|
|
require.Zero(t, recorder.completions)
|
|
})
|
|
|
|
t.Run("exclusive violation records no lifecycle", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
recorder := &recordingToolBillingRecorder{}
|
|
outcome, err := chatloop.ExecuteLocalTools(context.Background(), chatloop.ExecuteLocalToolsOptions{
|
|
Clock: quartz.NewMock(t),
|
|
ExclusiveToolNames: map[string]bool{"exclusive_tool": true},
|
|
BillingRecorder: recorder,
|
|
ToolCalls: []fantasy.ToolCallContent{
|
|
{ToolCallID: "call-1", ToolName: "exclusive_tool", Input: "{}"},
|
|
{ToolCallID: "call-2", ToolName: "fast_tool", Input: "{}"},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, outcome.Content, 2, "the whole batch resolves to synthesized policy errors")
|
|
require.Zero(t, recorder.starts)
|
|
require.Zero(t, recorder.completions)
|
|
})
|
|
}
|
|
|
|
func TestExecuteLocalTools_EmptyToolCallIDStillBillsWindow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
clock := quartz.NewMock(t)
|
|
trap := clock.Trap().Now()
|
|
defer trap.Close()
|
|
|
|
idlessGo := make(chan struct{})
|
|
fastGo := make(chan struct{})
|
|
resultCh := executeToolBatch(t, clock, chatloop.ExecuteLocalToolsOptions{
|
|
Tools: []fantasy.AgentTool{
|
|
blockingTool("idless_tool", idlessGo, fantasy.NewTextResponse("done")),
|
|
blockingTool("fast_tool", fastGo, fantasy.NewTextResponse("done")),
|
|
},
|
|
ActiveTools: []string{"idless_tool", "fast_tool"},
|
|
ToolCalls: []fantasy.ToolCallContent{
|
|
{ToolCallID: "", ToolName: "idless_tool", Input: "{}"},
|
|
{ToolCallID: "call-fast", ToolName: "fast_tool", Input: "{}"},
|
|
},
|
|
})
|
|
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
clock.Advance(10 * time.Second)
|
|
close(fastGo)
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
clock.Advance(50 * time.Second)
|
|
close(idlessGo)
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
|
|
outcome := testutil.RequireReceive(ctx, t, resultCh)
|
|
require.Equal(t, 60*time.Second, outcome.BatchRuntime)
|
|
}
|
|
|
|
func TestExecuteLocalTools_BillingRecorderReportsLiveCompletions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
clock := quartz.NewMock(t)
|
|
trap := clock.Trap().Now()
|
|
defer trap.Close()
|
|
|
|
recorder := &recordingToolBillingRecorder{
|
|
completeCh: make(chan recordedToolCompletion, 2),
|
|
}
|
|
fastGo := make(chan struct{})
|
|
slowGo := make(chan struct{})
|
|
resultCh := executeToolBatch(t, clock, chatloop.ExecuteLocalToolsOptions{
|
|
Tools: []fantasy.AgentTool{
|
|
blockingTool("fast_tool", fastGo, fantasy.NewTextResponse("done")),
|
|
blockingTool("slow_tool", slowGo, fantasy.NewTextResponse("done")),
|
|
},
|
|
ActiveTools: []string{"fast_tool", "slow_tool"},
|
|
BillingRecorder: recorder,
|
|
ToolCalls: []fantasy.ToolCallContent{
|
|
{ToolCallID: "call-fast", ToolName: "fast_tool", Input: "{}"},
|
|
{ToolCallID: "call-slow", ToolName: "slow_tool", Input: "{}"},
|
|
},
|
|
})
|
|
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
clock.Advance(10 * time.Second)
|
|
close(fastGo)
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
fast := testutil.RequireReceive(ctx, t, recorder.completeCh)
|
|
require.Equal(t, 0, fast.dispatchIndex)
|
|
clock.Advance(50 * time.Second)
|
|
close(slowGo)
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
slow := testutil.RequireReceive(ctx, t, recorder.completeCh)
|
|
require.Equal(t, 1, slow.dispatchIndex)
|
|
require.Equal(t, 50*time.Second, slow.completedAt.Sub(fast.completedAt))
|
|
|
|
outcome := testutil.RequireReceive(ctx, t, resultCh)
|
|
require.Equal(t, map[string]time.Time{
|
|
"call-fast": fast.completedAt,
|
|
"call-slow": slow.completedAt,
|
|
}, outcome.ToolResultCreatedAt)
|
|
}
|
|
|
|
func TestExecuteLocalTools_SerialCallBillsFromItsOwnStart(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
clock := quartz.NewMock(t)
|
|
trap := clock.Trap().Now()
|
|
defer trap.Close()
|
|
|
|
waitGo := make(chan struct{})
|
|
serialGo := make(chan struct{})
|
|
resultCh := executeToolBatch(t, clock, chatloop.ExecuteLocalToolsOptions{
|
|
Tools: []fantasy.AgentTool{
|
|
blockingTool("wait_agent", waitGo, fantasy.NewTextResponse("child report")),
|
|
serialTool{blockingTool("serial_tool", serialGo, fantasy.NewTextResponse("done"))},
|
|
},
|
|
ActiveTools: []string{"wait_agent", "serial_tool"},
|
|
UnbilledToolNames: map[string]bool{"wait_agent": true},
|
|
ToolCalls: []fantasy.ToolCallContent{
|
|
{ToolCallID: "call-wait", ToolName: "wait_agent", Input: "{}"},
|
|
{ToolCallID: "call-serial", ToolName: "serial_tool", Input: "{}"},
|
|
},
|
|
})
|
|
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
clock.Advance(10 * time.Minute)
|
|
close(waitGo)
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
// Release the serial start timestamp.
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
clock.Advance(2 * time.Second)
|
|
close(serialGo)
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
|
|
outcome := testutil.RequireReceive(ctx, t, resultCh)
|
|
require.Equal(t, 2*time.Second, outcome.BatchRuntime,
|
|
"a serial call bills its own execution, not the unbilled wait that delayed its launch")
|
|
}
|
|
|
|
func TestExecuteLocalTools_SerialAfterBilledSiblingBillsUnion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
clock := quartz.NewMock(t)
|
|
trap := clock.Trap().Now()
|
|
defer trap.Close()
|
|
|
|
execGo := make(chan struct{})
|
|
waitGo := make(chan struct{})
|
|
serialGo := make(chan struct{})
|
|
resultCh := executeToolBatch(t, clock, chatloop.ExecuteLocalToolsOptions{
|
|
Tools: []fantasy.AgentTool{
|
|
blockingTool("execute", execGo, fantasy.NewTextResponse("done")),
|
|
blockingTool("wait_agent", waitGo, fantasy.NewTextResponse("child report")),
|
|
serialTool{blockingTool("serial_tool", serialGo, fantasy.NewTextResponse("done"))},
|
|
},
|
|
ActiveTools: []string{"execute", "wait_agent", "serial_tool"},
|
|
UnbilledToolNames: map[string]bool{"wait_agent": true},
|
|
ToolCalls: []fantasy.ToolCallContent{
|
|
{ToolCallID: "call-execute", ToolName: "execute", Input: "{}"},
|
|
{ToolCallID: "call-wait", ToolName: "wait_agent", Input: "{}"},
|
|
{ToolCallID: "call-serial", ToolName: "serial_tool", Input: "{}"},
|
|
},
|
|
})
|
|
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
clock.Advance(3 * time.Second)
|
|
close(execGo)
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
clock.Advance(7 * time.Second)
|
|
close(waitGo)
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
// Release the serial start timestamp.
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
clock.Advance(2 * time.Second)
|
|
close(serialGo)
|
|
trap.MustWait(ctx).MustRelease(ctx)
|
|
|
|
outcome := testutil.RequireReceive(ctx, t, resultCh)
|
|
require.Equal(t, 5*time.Second, outcome.BatchRuntime,
|
|
"the 3s concurrent window and the 2s serial window bill; the 7s span where only wait_agent ran does not")
|
|
}
|
|
|
|
func TestBilledIntervalsDuration(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
at := base.Add
|
|
for _, tc := range []struct {
|
|
name string
|
|
intervals []chatloop.BilledInterval
|
|
want time.Duration
|
|
}{
|
|
{name: "empty", want: 0},
|
|
{
|
|
name: "overlapping intervals bill once",
|
|
intervals: []chatloop.BilledInterval{
|
|
{Start: at(0), End: at(10 * time.Second)},
|
|
{Start: at(0), End: at(4 * time.Second)},
|
|
},
|
|
want: 10 * time.Second,
|
|
},
|
|
{
|
|
name: "gap between intervals is not billed",
|
|
intervals: []chatloop.BilledInterval{
|
|
{Start: at(0), End: at(3 * time.Second)},
|
|
{Start: at(10 * time.Second), End: at(12 * time.Second)},
|
|
},
|
|
want: 5 * time.Second,
|
|
},
|
|
{
|
|
name: "unsorted contained interval adds nothing",
|
|
intervals: []chatloop.BilledInterval{
|
|
{Start: at(2 * time.Second), End: at(4 * time.Second)},
|
|
{Start: at(0), End: at(10 * time.Second)},
|
|
},
|
|
want: 10 * time.Second,
|
|
},
|
|
{
|
|
name: "touching intervals merge without a gap",
|
|
intervals: []chatloop.BilledInterval{
|
|
{Start: at(0), End: at(3 * time.Second)},
|
|
{Start: at(3 * time.Second), End: at(5 * time.Second)},
|
|
},
|
|
want: 5 * time.Second,
|
|
},
|
|
{
|
|
name: "inverted interval is ignored",
|
|
intervals: []chatloop.BilledInterval{
|
|
{Start: at(5 * time.Second), End: at(0)},
|
|
{Start: at(0), End: at(2 * time.Second)},
|
|
},
|
|
want: 2 * time.Second,
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
require.Equal(t, tc.want, chatloop.BilledIntervalsDuration(tc.intervals))
|
|
})
|
|
}
|
|
}
|