Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions coderd/database/querier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11190,13 +11190,13 @@ func TestGetTotalChatMessageRuntimeMsInRange(t *testing.T) {
LastModelConfigID: mc.ID,
})

insertMessage := func(chatID uuid.UUID, runtimeMs int64, createdAt time.Time, deleted bool) {
insertMessage := func(chatID uuid.UUID, role database.ChatMessageRole, runtimeMs int64, createdAt time.Time, deleted bool) {
t.Helper()
msg := dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: chatID,
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
ModelConfigID: uuid.NullUUID{UUID: mc.ID, Valid: true},
Role: database.ChatMessageRoleAssistant,
Role: role,
RuntimeMs: sql.NullInt64{Int64: runtimeMs, Valid: true},
})
_, err := sqlDB.ExecContext(ctx, "UPDATE chat_messages SET created_at = $1, deleted = $2 WHERE id = $3", createdAt, deleted, msg.ID)
Expand All @@ -11205,22 +11205,24 @@ func TestGetTotalChatMessageRuntimeMsInRange(t *testing.T) {

// Counted: on the inclusive start boundary, in the middle (across two
// chats), soft-deleted, and just before the exclusive end boundary.
insertMessage(chat1.ID, 1, rangeStart, false)
insertMessage(chat2.ID, 2, rangeStart.Add(30*time.Minute), false)
insertMessage(chat1.ID, 4, rangeStart.Add(45*time.Minute), true)
insertMessage(chat1.ID, 8, rangeEnd.Add(-time.Second), false)
insertMessage(chat1.ID, database.ChatMessageRoleAssistant, 1, rangeStart, false)
insertMessage(chat2.ID, database.ChatMessageRoleAssistant, 2, rangeStart.Add(30*time.Minute), false)
insertMessage(chat1.ID, database.ChatMessageRoleAssistant, 4, rangeStart.Add(45*time.Minute), true)
insertMessage(chat1.ID, database.ChatMessageRoleAssistant, 8, rangeEnd.Add(-time.Second), false)
// Tool rows count because runtime totals are role-agnostic.
insertMessage(chat1.ID, database.ChatMessageRoleTool, 64, rangeStart.Add(20*time.Minute), false)
// Not counted: before the range, on the exclusive end boundary, and a
// NULL runtime (runtime 0 is stored as NULL).
insertMessage(chat1.ID, 16, rangeStart.Add(-time.Second), false)
insertMessage(chat1.ID, 32, rangeEnd, false)
insertMessage(chat1.ID, 0, rangeStart.Add(10*time.Minute), false)
insertMessage(chat1.ID, database.ChatMessageRoleAssistant, 16, rangeStart.Add(-time.Second), false)
insertMessage(chat1.ID, database.ChatMessageRoleAssistant, 32, rangeEnd, false)
insertMessage(chat1.ID, database.ChatMessageRoleAssistant, 0, rangeStart.Add(10*time.Minute), false)

total, err = db.GetTotalChatMessageRuntimeMsInRange(ctx, database.GetTotalChatMessageRuntimeMsInRangeParams{
StartTime: rangeStart,
EndTime: rangeEnd,
})
require.NoError(t, err)
require.EqualValues(t, 15, total)
require.EqualValues(t, 79, total)
}

func TestListUsageEventCreatedAtsByTypeSince(t *testing.T) {
Expand Down
14 changes: 9 additions & 5 deletions coderd/usage/usagetypes/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,11 +203,15 @@ func (e HBAISeats) Fields() map[string]any {
}

// HBAgentRuntime is the event associated with hb_agent_runtime_v1. RuntimeMs
// is the total agent-loop runtime in milliseconds consumed by Coder Agents
// (chats) in one UTC hour. Each measured step spans model streaming (including
// provider-executed tools) and stream retries, and ends when the model stream
// finishes. Time spent executing local tools between steps, including
// sub-agents that bill their own model calls, is excluded.
// is total Coder Agent chat runtime in milliseconds for one UTC hour.
//
// Model steps bill provider streaming. Local tool batches bill the union of
// billed execution intervals, so parallel calls count once and serial calls
// count only from their own start.
//
// Excluded: sub-agent orchestration, client and external-agent work, user or
// idle waits, and retry backoff. Server-executed tools count even when their
// work runs in a connected workspace.
//
// This measures the new Coder Agents (the `chats` tables), not the deprecated
// Tasks counted by dc_managed_agents_v1.
Expand Down
6 changes: 6 additions & 0 deletions coderd/x/chatd/attempt.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ type stepData struct {
ContextLimit sql.NullInt64
Runtime time.Duration

// BatchRuntime is the local-tool batch window. Model steps use Runtime.
BatchRuntime time.Duration
// BatchBilledCalls counts the calls whose intervals produced
// BatchRuntime. Audit metadata for the batch usage record.
BatchBilledCalls int

ToolCallCreatedAt map[string]time.Time
ToolResultCreatedAt map[string]time.Time
ReasoningStartedAt []time.Time
Expand Down
9 changes: 9 additions & 0 deletions coderd/x/chatd/chatd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6591,6 +6591,15 @@ func TestActiveServer_ToolExecutionAndPolicy(t *testing.T) {
require.False(t, result.ProviderExecuted)
}
}

// Batch runtime bills a dedicated model-only usage row, so no
// user-visible tool row ever carries runtime.
messages := chatMessages(ctx, t, db, chat.ID)
for _, msg := range messages {
if msg.Role == database.ChatMessageRoleTool {
require.False(t, msg.RuntimeMs.Valid)
}
}
})
}

Expand Down
179 changes: 133 additions & 46 deletions coderd/x/chatd/chatloop/chatloop.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,15 @@ type PersistedStep struct {
Content []fantasy.Content
Usage fantasy.Usage
ContextLimit sql.NullInt64
// Runtime is the wall-clock duration of the model invocation
// that produced this step's content, measured from just before
// the provider stream is opened until the stream is fully
// consumed.
// Runtime is the wall-clock duration from opening to consuming the
// model stream.
Runtime time.Duration
// BatchRuntime is the union of billed local-tool execution intervals.
// Parallel calls count once and serial calls count from their own start.
BatchRuntime time.Duration
// BatchBilledCalls counts the executed calls whose intervals produced
// BatchRuntime. Audit metadata for the batch usage record.
BatchBilledCalls int
// PendingDynamicToolCalls lists tool calls that target
// dynamic tools. When non-empty the chatloop exits with
// ErrDynamicToolCall so the caller can execute them
Expand Down Expand Up @@ -269,12 +273,32 @@ type ExecuteLocalToolsOptions struct {
// is renamed but old chat histories still reference the old name.
ToolNameAliases map[string]string

// UnbilledToolNames lists called tool names excluded from the batch
// window. Include deprecated aliases.
UnbilledToolNames map[string]bool
// BillingRecorder observes each local call's start and completion
// for interrupt billing. Serial calls may start after concurrent
// siblings settle, so interrupts bill actual starts and skip calls
// that never run. Optional.
BillingRecorder ToolBillingRecorder

PublishMessagePart func(codersdk.ChatMessageRole, codersdk.ChatMessagePart)
Logger slog.Logger
Metrics *Metrics
Clock quartz.Clock
}

// ToolBillingRecorder records live start and completion timestamps for
// local tool calls. Interrupt billing uses these so a cancel can bill
// work that already started and skip calls that never ran.
// dispatchIndex identifies the dispatch-order occurrence.
// RecordComplete may run from multiple tool goroutines; implementations
// must be concurrency-safe.
type ToolBillingRecorder interface {
RecordStart(dispatchIndex int, startedAt time.Time)
RecordComplete(dispatchIndex int, completedAt time.Time)
}

// GenerateCompactionOptions configures one context compaction call.
type GenerateCompactionOptions struct {
Model fantasy.LanguageModel
Expand Down Expand Up @@ -620,7 +644,8 @@ func ExecuteLocalTools(ctx context.Context, opts ExecuteLocalToolsOptions) (Pers
}

maxResultBytes := toolResultByteBudget(opts.ContextLimit)
toolResults := executeTools(
batchStart := clockNow(opts.Clock)
toolExecutions := executeTools(
ctx,
opts.Clock,
opts.Tools,
Expand All @@ -636,26 +661,83 @@ func ExecuteLocalTools(ctx context.Context, opts ExecuteLocalToolsOptions) (Pers
opts.BuiltinToolNames,
maxResultBytes,
opts.ToolNameAliases,
func(tr fantasy.ToolResultContent, completedAt time.Time) {
recordToolResultTimestamp(&result, tr.ToolCallID, completedAt)
publishToolAttachments(ctx, opts.Logger, tr, completedAt, publishMessagePart)
ssePart := chatprompt.PartFromContentWithLogger(ctx, opts.Logger, tr)
ssePart.CreatedAt = &completedAt
publishMessagePart(codersdk.ChatMessageRoleTool, ssePart)
},
batchStart,
opts.BillingRecorder,
)
for _, execution := range toolExecutions {
tr := execution.content
completedAt := execution.interval.End
recordToolResultTimestamp(&result, tr.ToolCallID, completedAt)
publishToolAttachments(ctx, opts.Logger, tr, completedAt, publishMessagePart)
ssePart := chatprompt.PartFromContentWithLogger(ctx, opts.Logger, tr)
ssePart.CreatedAt = &completedAt
publishMessagePart(codersdk.ChatMessageRoleTool, ssePart)
result.content = append(result.content, tr)
}
if ctx.Err() != nil {
return PersistedStep{}, ctx.Err()
}
for _, tr := range toolResults {
result.content = append(result.content, tr)
}
billedIntervals := billableBatchIntervals(toolExecutions, opts.UnbilledToolNames)
return PersistedStep{
Content: result.content,
ToolResultCreatedAt: result.toolResultCreatedAt,
BatchRuntime: BilledIntervalsDuration(billedIntervals),
BatchBilledCalls: len(billedIntervals),
}, nil
}

// billableBatchIntervals returns the billed execution intervals.
// Unbilled tools and calls without both stamps do not count.
func billableBatchIntervals(
executions []toolExecutionResult,
unbilledToolNames map[string]bool,
) []BilledInterval {
intervals := make([]BilledInterval, 0, len(executions))
for _, execution := range executions {
if unbilledToolNames[execution.content.ToolName] ||
execution.interval.Start.IsZero() ||
execution.interval.End.IsZero() {
continue
}
intervals = append(intervals, execution.interval)
}
return intervals
}

// BilledInterval is one billed tool call's execution window.
type BilledInterval struct {
Start time.Time
End time.Time
}

// BilledIntervalsDuration returns the union duration of valid intervals.
// Overlaps count once, gaps do not, and inverted intervals are ignored.
// Committed and interrupted batches share this helper.
func BilledIntervalsDuration(intervals []BilledInterval) time.Duration {
valid := slices.DeleteFunc(slices.Clone(intervals), func(iv BilledInterval) bool {
return iv.End.Before(iv.Start)
})
if len(valid) == 0 {
return 0
}
slices.SortFunc(valid, func(a, b BilledInterval) int {
return a.Start.Compare(b.Start)
})
curStart, curEnd := valid[0].Start, valid[0].End
var total time.Duration
for _, iv := range valid[1:] {
if iv.Start.After(curEnd) {
total += curEnd.Sub(curStart)
curStart, curEnd = iv.Start, iv.End
continue
}
if iv.End.After(curEnd) {
curEnd = iv.End
}
}
return total + curEnd.Sub(curStart)
}

// prepareMessagesForRequest applies the prompt preparation pipeline used
// immediately before sending messages to a provider. It returns the
// possibly updated canonical messages and an independent provider-ready
Expand Down Expand Up @@ -1081,10 +1163,14 @@ func processStepStream(
return result, nil
}

// executeTools runs all tool calls concurrently after the stream
// completes. Results are published via onResult in the original
// tool-call order after all tools finish, preserving deterministic
// event ordering for SSE subscribers.
type toolExecutionResult struct {
content fantasy.ToolResultContent
interval BilledInterval
}

// executeTools runs non-serial calls concurrently, then SerialToolCalls in
// call order. Results are returned in original order after all tools finish.
// recorder, if set, receives live start and completion timestamps.
func executeTools(
ctx context.Context,
clock quartz.Clock,
Expand All @@ -1100,8 +1186,9 @@ func executeTools(
builtinToolNames map[string]bool,
maxResultBytes int,
toolNameAliases map[string]string,
onResult func(fantasy.ToolResultContent, time.Time),
) []fantasy.ToolResultContent {
batchStart time.Time,
recorder ToolBillingRecorder,
) []toolExecutionResult {
if len(toolCalls) == 0 {
return nil
}
Expand Down Expand Up @@ -1150,12 +1237,11 @@ func executeTools(
}
notifyStepToolCallObservers(toolMap, toolNameAliases, observed)

results := make([]fantasy.ToolResultContent, len(localToolCalls))
completedAt := make([]time.Time, len(localToolCalls))
executions := make([]toolExecutionResult, len(localToolCalls))
runCall := func(i int, tc fantasy.ToolCallContent) {
defer func() {
if r := recover(); r != nil {
results[i] = fantasy.ToolResultContent{
executions[i].content = fantasy.ToolResultContent{
ToolCallID: tc.ToolCallID,
ToolName: tc.ToolName,
Result: fantasy.ToolResultOutputContentError{
Expand All @@ -1166,9 +1252,13 @@ func executeTools(
// Record when this tool completed (or panicked).
// Captured per call so parallel tools get
// accurate individual completion times.
completedAt[i] = clockNow(clock)
completedAt := clockNow(clock)
executions[i].interval.End = completedAt
if recorder != nil {
recorder.RecordComplete(i, completedAt)
}
}()
results[i] = executeSingleTool(
executions[i].content = executeSingleTool(
ctx,
toolMap,
tc,
Expand All @@ -1185,19 +1275,19 @@ func executeTools(
toolNameAliases,
)
}
// Calls to tools that opt in via SerialToolCalls run in tool-call
// order after every concurrent sibling has settled. The step waits
// for all calls anyway, so sequencing them last costs nothing, and
// order-sensitive shared state (for example the find_tools
// activation budget) is claimed deterministically after sibling
// outcomes are known. All other calls stay concurrent.
// SerialToolCalls run in call order after concurrent siblings settle, so
// order-sensitive state observes final sibling outcomes.
var serialIndexes []int
var wg sync.WaitGroup
for i, tc := range localToolCalls {
if isSerialToolCall(toolMap, toolNameAliases, tc.ToolName) {
serialIndexes = append(serialIndexes, i)
continue
}
executions[i].interval.Start = batchStart
if recorder != nil {
recorder.RecordStart(i, batchStart)
}
wg.Add(1)
go func() {
defer wg.Done()
Expand All @@ -1206,29 +1296,26 @@ func executeTools(
}
wg.Wait()

// Reconcile settled sibling outcomes before serial tools run, so
// for example find_tools refunds reservations of errored direct
// calls before its searches admit activations.
settled := make([]fantasy.ToolResultContent, 0, len(results))
for i := range results {
// Reconcile concurrent results before serial tools inspect shared state.
settled := make([]fantasy.ToolResultContent, 0, len(executions))
for i := range executions {
if !slices.Contains(serialIndexes, i) {
settled = append(settled, results[i])
settled = append(settled, executions[i].content)
}
}
notifyStepToolResultObservers(toolMap, toolNameAliases, localToolCalls, observed, settled)

for _, i := range serialIndexes {
// Stamp serial calls at launch, not batch start.
startedAt := clockNow(clock)
executions[i].interval.Start = startedAt
if recorder != nil {
recorder.RecordStart(i, startedAt)
}
runCall(i, localToolCalls[i])
}

// Publish results in the original tool-call order so SSE
// subscribers see a deterministic event sequence.
if onResult != nil {
for i, tr := range results {
onResult(tr, completedAt[i])
}
}
return results
return executions
}

// applyExclusiveToolPolicy checks whether toolCalls violate the
Expand Down
Loading
Loading