Files
multica/server/pkg/agent/codebuddy.go
74ff8aabd7 MUL-7983: fix(agent): handle CodeBuddy startup errors without aborting active runs (#9214)
* fix(agent): fail codebuddy runs that emit a top-level error event

CodeBuddy's startup-failure shape is one top-level {"type":"error"}
line followed by a process that stays alive and silent: with an
inherited SERVER__PORT (a user-scope config shared by several tools),
a second concurrent run of the same agent fails to listen, prints
{"type":"error","error":"listen EADDRINUSE: ..."} and never emits
anything again. The parser had no branch for that event type, so the
run sat at "waiting for agent response" until the idle watchdog
stopped it - 19 of 48 runs hung this way on the reporter's machine.

- type=error is now fatal when no terminal result has been seen: the
  CLI's own message becomes the run error and the hung process is torn
  down immediately instead of idling. A result already seen keeps
  governing the outcome, so a late informational error line cannot
  flip a finished run.
- buildCodebuddyEnv now also forces SERVER__PORT=0 (OS-assigned port
  per run, so concurrent runs never share one) and
  CODEBUDDY_BASH_AUTO_BACKGROUND_DISABLED=1 (CodeBuddy's PowerShell
  tool moves timed-out commands to the background and only honors that
  variable). Both are appended last like the existing background-task
  disable so a caller-provided value cannot outvote them.

Fixes #9200

Generated-by: GLM-5.3-Flash (ZCode)

* fix(agent): keep CodeBuddy running after recoverable errors

MUL-7983

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Yushen <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-13 13:54:24 +08:00

729 lines
25 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os/exec"
"strings"
"sync"
"time"
)
// codebuddyBackend implements Backend by spawning the CodeBuddy CLI
// (a Claude Code fork) with --output-format stream-json.
// It mirrors claude.go's execution model: concurrent stdin/stdout to
// avoid pipe deadlocks, open stdin for control_request auto-approval,
// and runContext for zero-timeout = no-deadline semantics.
type codebuddyBackend struct {
cfg Config
}
// codebuddyBlockedArgs are flags hardcoded by the daemon that must not be
// overridden by user-configured custom_args. Overriding these would break
// the daemon↔codebuddy communication protocol.
var codebuddyBlockedArgs = map[string]blockedArgMode{
"-p": blockedStandalone, // non-interactive mode
"--output-format": blockedWithValue, // stream-json protocol
"--input-format": blockedWithValue, // stream-json protocol
"--permission-mode": blockedWithValue, // bypassPermissions for autonomous operation
"--mcp-config": blockedWithValue, // set by daemon from agent.mcp_config
// `--effort` is owned by the per-agent thinking_level picker so a
// user-supplied custom_arg cannot silently outvote it.
"--effort": blockedWithValue,
}
func buildCodebuddyArgs(opts ExecOptions, logger *slog.Logger) []string {
args := []string{
"-p",
"--output-format", "stream-json",
"--input-format", "stream-json",
"--verbose",
"--permission-mode", "bypassPermissions",
// CodeBuddy's interactive tools have no UI to render in under the
// daemon's headless stream-json transport. AskUserQuestion and
// ExitPlanMode are both exempted from CodeBuddy's permission-mode
// finalization, so --permission-mode bypassPermissions does NOT
// auto-approve them — they always reach the permission bridge and
// stall the turn waiting for a confirmation nobody can give
// (GitHub #6012). EnterPlanMode is denied alongside them: leaving it
// enabled would let the model enter a plan mode it then has no tool
// to leave. Plan-shaped work still happens — the plan is written as
// ordinary assistant output instead of behind an approval gate.
//
// Pass one value per tool: --disallowedTools is variadic and
// CodeBuddy compares each entry against the tool name exactly
// (PermissionUtils.matchPermissionRules), so a comma-joined string
// would match nothing despite what the CLI's own help text claims.
"--disallowedTools", "AskUserQuestion", "EnterPlanMode", "ExitPlanMode",
}
// NOTE: --strict-mcp-config is deliberately never passed. It means "only
// use servers from --mcp-config", which drops CodeBuddy's user, project
// AND local scopes. Measured against CodeBuddy 2.x with one real MCP
// server registered per scope, using process spawns as the oracle:
//
// --mcp-config only ....... managed + user + local <- what we want
// --mcp-config + strict ... managed only
// strict only ............. nothing at all
//
// The union is also what mergeRuntimeAndAgentMcpConfig promises: adding
// one managed server must not disable the user's own. CodeBuddy applies
// it natively, and a managed entry already wins a same-name collision
// (verified), so the daemon does not pre-merge — see the codebuddy note
// in loadRuntimeMcpServerConfigs.
if opts.Model != "" {
args = append(args, "--model", opts.Model)
}
if opts.ThinkingLevel != "" {
args = append(args, "--effort", opts.ThinkingLevel)
}
if opts.MaxTurns > 0 {
args = append(args, "--max-turns", fmt.Sprintf("%d", opts.MaxTurns))
}
if opts.SystemPrompt != "" {
args = append(args, "--append-system-prompt", opts.SystemPrompt)
}
if opts.ResumeSessionID != "" {
args = append(args, "--resume", opts.ResumeSessionID)
}
args = append(args, filterCustomArgs(opts.ExtraArgs, codebuddyBlockedArgs, logger)...)
args = append(args, filterCustomArgs(opts.CustomArgs, codebuddyBlockedArgs, logger)...)
return args
}
func (b *codebuddyBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
execPath := b.cfg.ExecutablePath
if execPath == "" {
execPath = "codebuddy"
}
if _, err := exec.LookPath(execPath); err != nil {
return nil, fmt.Errorf("codebuddy executable not found at %q: %w", execPath, err)
}
timeout := opts.Timeout
runCtx, cancel := runContext(ctx, timeout)
args := buildCodebuddyArgs(opts, b.cfg.Logger)
// A managed agent config is written to a temp file and added with
// --mcp-config, which ADDS to whatever CodeBuddy loads from its own user,
// project and local scopes — buildCodebuddyArgs never passes
// --strict-mcp-config, so those stay on. A managed entry wins a same-name
// collision natively.
//
// Three-state, as everywhere else: a JSON `null` means "inherit the runtime
// configuration" and skips the flag entirely, while an explicitly empty
// object is a managed set that happens to contain no servers.
var mcpConfigPath string
var mcpFileCleanup func()
if hasManagedMcpConfig(opts.McpConfig) {
path, err := writeMcpConfigToTemp(opts.McpConfig)
if err != nil {
cancel()
return nil, err
}
mcpConfigPath = path
mcpFileCleanup = func() { cleanupMcpConfigTemp(mcpConfigPath) }
args = append(args, "--mcp-config", mcpConfigPath)
}
// Clean up the temp file if we return before the goroutine takes ownership.
defer func() {
if mcpFileCleanup != nil {
mcpFileCleanup()
}
}()
cmd := b.cfg.commandAt(execPath).exec(runCtx, args...)
hideAgentWindow(cmd)
b.cfg.logAgentCommand(cmd, newAgentCommandLogArgs(args))
cmd.WaitDelay = 10 * time.Second
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
// Multica closes stdin after the first result and cannot wait for
// cross-turn task_notification events. Force CodeBuddy's documented
// headless disable so Bash/PowerShell/Agent never take the background
// path (CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS; see CLI headless docs).
cmd.Env = buildCodebuddyEnv(b.cfg.Env)
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("codebuddy stdout pipe: %w", err)
}
stdin, err := cmd.StdinPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("codebuddy stdin pipe: %w", err)
}
var closeStdinOnce sync.Once
closeStdin := func() { closeStdinOnce.Do(func() { _ = stdin.Close() }) }
stderrBuf := newStderrTail(newLogWriter(b.cfg.Logger, "[codebuddy:stderr] "), agentStderrTailBytes)
cmd.Stderr = stderrBuf
if err := startOwnedProcessTree(cmd, b.cfg.Logger); err != nil {
closeStdin()
cancel()
return nil, fmt.Errorf("start codebuddy: %w", err)
}
b.cfg.Logger.Info("codebuddy started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
// cmd.Start() succeeded — transfer temp file ownership to the goroutine.
mcpFileCleanup = nil
msgCh := make(chan Message, 256)
resCh := make(chan Result, 1)
// Write the prompt in a dedicated goroutine to prevent deadlock.
// CodeBuddy (like Claude Code) emits a startup banner to stdout before
// reading stdin; a synchronous write would block once the pipe buffer
// fills. Keep stdin open after writing so control_request events can
// be answered mid-run.
writeDone := make(chan error, 1)
go func() {
err := writeCodebuddyInput(stdin, prompt)
if err != nil {
closeStdin()
}
writeDone <- err
}()
go func() {
defer cancel()
defer close(msgCh)
defer close(resCh)
if mcpConfigPath != "" {
defer cleanupMcpConfigTemp(mcpConfigPath)
}
startTime := time.Now()
var lastAssistantText string
var finalResultText string
sawResult := false
resultIsError := false
sawBackgroundTask := false
sawStartedEvent := false
topLevelError := ""
fatalStartupError := false
var sessionID string
usage := make(map[string]TokenUsage)
seenUsage := make(map[string]struct{})
eventCount := 0
invalidEventCount := 0
assistantEventCount := 0
toolUseCount := 0
unreadableAssistantCount := 0
// Close stdout when the context is cancelled so scanner.Scan() unblocks.
go func() {
<-runCtx.Done()
closeStdin()
_ = stdout.Close()
}()
scanner := newAgentStreamScanner(stdout)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var msg codebuddySDKMessage
if err := json.Unmarshal([]byte(line), &msg); err != nil {
invalidEventCount++
continue
}
eventCount++
switch msg.Type {
case "assistant":
sawStartedEvent = true
assistantEventCount++
turn := b.handleAssistant(msg, msgCh, usage, seenUsage)
toolUseCount += turn.toolUses
if !turn.understood {
unreadableAssistantCount++
}
lastAssistantText = turn.resolveFallback(lastAssistantText)
case "user":
sawStartedEvent = true
b.handleUser(msg, msgCh)
case "system":
sawStartedEvent = true
if msg.SessionID != "" {
sessionID = msg.SessionID
}
// CodeBuddy background lifecycle rides on system subtypes
// (task_started / task_progress / task_updated /
// task_notification), not Claude's async_launched tool_result.
// With CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS these should
// never appear; if they do, fail rather than report success.
if codebuddySystemIsBackgroundTask(msg.Subtype) {
sawBackgroundTask = true
}
trySend(msgCh, Message{Type: MessageStatus, Status: "running", SessionID: sessionID})
case "result":
sawResult = true
finalResultText = msg.ResultText
resultIsError = msg.IsError
sessionID = msg.SessionID
if resultUsage := codebuddyResultUsage(msg, opts.Model); len(resultUsage) > 0 {
usage = resultUsage
}
closeStdin()
case "error":
// Startup failures can leave CodeBuddy alive and silent (issue
// #9200). Once a run has emitted system/assistant activity, the
// same event can be recoverable, so keep the process running.
if sawResult || fatalStartupError {
break
}
topLevelError = strings.TrimSpace(msg.Error)
if topLevelError == "" {
topLevelError = "codebuddy emitted a top-level error event without details"
}
if !sawStartedEvent && runCtx.Err() == nil {
fatalStartupError = true
// Cancel only before the run starts; this also releases the
// scanner and cmd.Wait when the CLI remains alive.
cancel()
} else {
trySend(msgCh, Message{Type: MessageLog, Level: "error", Content: topLevelError})
}
case "log":
if msg.Log != nil {
trySend(msgCh, Message{
Type: MessageLog,
Level: msg.Log.Level,
Content: msg.Log.Message,
})
}
case "control_request":
b.handleControlRequest(msg, stdin)
}
}
scanErr := scanner.Err()
if scanErr != nil {
// Stop a malformed/oversized writer from blocking cmd.Wait after the
// scanner has stopped consuming stdout.
_ = stdout.Close()
}
closeStdin()
// Wait for process exit.
exitErr := cmd.Wait()
releaseProcessGroup(cmd)
duration := time.Since(startTime)
// writeDone is buffered (cap 1) and the writer always sends — by the
// time cmd has exited, the prompt write has either succeeded, hit a
// broken pipe, or been unblocked by the kill that ended cmd.
writeErr := <-writeDone
completionGuardError := ""
if sawBackgroundTask {
completionGuardError = "codebuddy emitted a background task system event; Multica-managed runs require foreground execution (set CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS=1)"
}
finalStatus, finalOutput, finalError := finalizeStreamResult(
"codebuddy",
timeout,
runCtx.Err(),
writeErr,
exitErr,
sessionID,
streamTerminalState{
lastAssistantText: lastAssistantText,
finalResultText: finalResultText,
sawResult: sawResult,
resultIsError: resultIsError,
scanErr: scanErr,
},
completionGuardError,
)
// A startup error explains our own cancellation. After the run has
// started, use its last error only if it exited without a result;
// preserve an external cancellation or timeout otherwise.
if topLevelError != "" && !sawResult && (fatalStartupError || runCtx.Err() == nil) {
finalStatus = "failed"
finalOutput = ""
finalError = topLevelError
}
// cmd.Wait() has returned — stderrBuf.Tail() is complete. Resume
// rejection phrases often land only on stderr (mirror Claude MUL-4966).
stderrTail := stderrBuf.Tail()
if finalError != "" {
finalError = withAgentStderr(finalError, "codebuddy", stderrTail)
}
logStreamProtocolObservation(b.cfg.Logger, streamProtocolObservation{
provider: "codebuddy",
cliVersion: b.cfg.CLIVersion,
model: opts.Model,
exitCode: streamProcessExitCode(exitErr),
eventCount: eventCount,
invalidEventCount: invalidEventCount,
assistantEventCount: assistantEventCount,
toolUseCount: toolUseCount,
sawResult: sawResult,
resultIsError: resultIsError,
resultBytes: len(finalResultText),
lastAssistantBytes: len(lastAssistantText),
scannerError: scanErr != nil,
unreadableAssistantCount: unreadableAssistantCount,
anthropicBaseURLConfigured: strings.TrimSpace(b.cfg.Env["ANTHROPIC_BASE_URL"]) != "",
})
b.cfg.Logger.Info("codebuddy finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
resumeRejected := resumeWasRejected(opts.ResumeSessionID, sessionID, finalStatus == "failed", finalError, stderrTail)
reportedSessionID := resolveSessionID(opts.ResumeSessionID, sessionID, finalStatus == "failed", finalError, stderrTail)
if resumeRejected {
b.cfg.Logger.Info("codebuddy resume was rejected; dropping session id and signalling fresh-session retry",
"requested_resume", opts.ResumeSessionID,
"emitted_session", sessionID,
)
}
resCh <- Result{
Status: finalStatus,
Output: finalOutput,
Error: finalError,
DurationMs: duration.Milliseconds(),
SessionID: reportedSessionID,
Usage: usage,
ResumeRejected: resumeRejected,
}
}()
return &Session{Messages: msgCh, Result: resCh}, nil
}
func (b *codebuddyBackend) handleAssistant(msg codebuddySDKMessage, ch chan<- Message, usage map[string]TokenUsage, seenUsage map[string]struct{}) assistantTurn {
var content codebuddyMessageContent
if err := json.Unmarshal(msg.Message, &content); err != nil {
// Unreadable body: understood stays false so the caller drops any
// fallback rather than let an older turn stand in for this one.
return assistantTurn{}
}
turn := assistantTurn{understood: true}
var assistantText strings.Builder
toolUseCount := 0
// CodeBuddy is a Claude Code fork and emits the same stream-json shapes, so
// it inherits the same accounting rule: one response can emit several
// assistant blocks carrying the same message ID and the same usage. Count
// its input/cache tokens once, without dropping any of the blocks below.
// Missing IDs retain best-effort per-event accounting. This fallback covers
// only the main loop: assistant output_tokens is a placeholder, and subagent
// totals require the final result's modelUsage. Mirrors claude.go.
// https://code.claude.com/docs/en/agent-sdk/cost-tracking#track-per-step-usage
_, counted := seenUsage[content.ID]
if msg.ParentToolUseID == "" && content.Usage != nil && content.Model != "" &&
(content.ID == "" || !counted) && codebuddyUsageHasTokens(
content.Usage.InputTokens, 0, content.Usage.CacheReadInputTokens, content.Usage.CacheCreationInputTokens,
) {
if content.ID != "" {
seenUsage[content.ID] = struct{}{}
}
u := usage[content.Model]
u.InputTokens += content.Usage.InputTokens
u.CacheReadTokens += content.Usage.CacheReadInputTokens
u.CacheWriteTokens += content.Usage.CacheCreationInputTokens
usage[content.Model] = u
}
for _, block := range content.Content {
switch block.Type {
case "text":
if block.Text != "" {
assistantText.WriteString(block.Text)
trySend(ch, Message{Type: MessageText, Content: block.Text})
}
case "thinking":
if block.Text != "" {
trySend(ch, Message{Type: MessageThinking, Content: block.Text})
}
case "tool_use":
toolUseCount++
var input map[string]any
if block.Input != nil {
_ = json.Unmarshal(block.Input, &input)
}
trySend(ch, Message{
Type: MessageToolUse,
Tool: block.Name,
CallID: block.ID,
Input: input,
})
default:
// A block type we do not render may be carrying the model's answer
// in a shape we cannot read, so we must not claim this turn was
// silent. Recognising a new no-text block is a deliberate one-line
// addition here, not an accident of falling through.
turn.understood = false
}
}
turn.text = assistantText.String()
turn.toolUses = toolUseCount
return turn
}
func (b *codebuddyBackend) handleUser(msg codebuddySDKMessage, ch chan<- Message) {
var content codebuddyMessageContent
if err := json.Unmarshal(msg.Message, &content); err != nil {
return
}
for _, block := range content.Content {
if block.Type == "tool_result" {
resultStr := ""
if block.Content != nil {
resultStr = string(block.Content)
}
trySend(ch, Message{
Type: MessageToolResult,
CallID: block.ToolUseID,
Output: resultStr,
})
}
}
}
func (b *codebuddyBackend) handleControlRequest(msg codebuddySDKMessage, stdin interface{ Write([]byte) (int, error) }) {
// Auto-approve all tool uses in autonomous/daemon mode.
var req codebuddyControlRequestPayload
if err := json.Unmarshal(msg.Request, &req); err != nil {
return
}
var inputMap map[string]any
if req.Input != nil {
_ = json.Unmarshal(req.Input, &inputMap)
}
if inputMap == nil {
inputMap = map[string]any{}
}
// Do not rewrite run_in_background here: under bypassPermissions most
// tools never emit control_request, and CodeBuddy does not use Claude's
// async_launched tool_result shape. Background work is disabled via
// CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS before tool execution.
response := map[string]any{
"type": "control_response",
"response": map[string]any{
"subtype": "success",
"request_id": msg.RequestID,
"response": map[string]any{
// CodeBuddy's SdkPermissionClient reads `allowed` and treats a
// missing key as a denial; `behavior` is Claude Code's spelling,
// which the fork still honours on its other permission paths.
// Send both so an approval is never read as a silent reject.
"allowed": true,
"behavior": "allow",
"updatedInput": inputMap,
},
},
}
data, err := json.Marshal(response)
if err != nil {
b.cfg.Logger.Warn("codebuddy: failed to marshal control response", "error", err)
return
}
data = append(data, '\n')
if _, err := stdin.Write(data); err != nil {
b.cfg.Logger.Warn("codebuddy: failed to write control response", "error", err)
}
}
func writeCodebuddyInput(w io.Writer, prompt string) error {
payload := map[string]any{
"type": "user",
"message": map[string]any{
"role": "user",
"content": []map[string]string{
{
"type": "text",
"text": prompt,
},
},
},
}
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal codebuddy input: %w", err)
}
data = append(data, '\n')
if _, err := w.Write(data); err != nil {
return err
}
return nil
}
const codebuddyDisableBackgroundTasksEnv = "CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS"
// CodeBuddy's PowerShell tool moves a command to the background when it hits
// its foreground timeout and only honors this variable — the headless
// background-task disable above does not stop it (issue #9200).
const codebuddyDisableBashAutoBackgroundEnv = "CODEBUDDY_BASH_AUTO_BACKGROUND_DISABLED"
// A user-scope SERVER__PORT (shared by several tools' configs) pins every
// codebuddy run's internal HTTP server to one port; a second concurrent run
// then dies with EADDRINUSE before doing any work (issue #9200). Port 0 lets
// the OS assign a free port per run.
const codebuddyInternalPortEnv = "SERVER__PORT"
// buildCodebuddyEnv merges task env and always forces background tasks off,
// a random internal HTTP port and the PowerShell auto-background disable.
// Multica's adapter closes stdin after the first result; CodeBuddy's
// background lifecycle can push task_notification after that point, which we
// cannot observe. The official CLI documents the disable variable for exactly
// that headless shape (https://www.codebuddy.ai/docs/cli/headless).
//
// Append the forced entries last so os/exec's platform-aware dedup
// (case-insensitive on Windows, last-wins) keeps them even when the
// inherited or custom_env key differs only by case.
func buildCodebuddyEnv(extra map[string]string) []string {
return append(buildEnv(extra),
codebuddyInternalPortEnv+"=0",
codebuddyDisableBashAutoBackgroundEnv+"=1",
codebuddyDisableBackgroundTasksEnv+"=1",
)
}
// codebuddySystemIsBackgroundTask reports CodeBuddy's real background-task
// system subtypes (not Claude's async_launched tool_result).
func codebuddySystemIsBackgroundTask(subtype string) bool {
switch strings.TrimSpace(subtype) {
case "task_started", "task_progress", "task_updated", "task_notification":
return true
default:
return false
}
}
// ── Codebuddy SDK JSON types ──
type codebuddySDKMessage struct {
Type string `json:"type"`
Message json.RawMessage `json:"message,omitempty"`
Subtype string `json:"subtype,omitempty"`
SessionID string `json:"session_id,omitempty"`
Model string `json:"model,omitempty"`
ParentToolUseID string `json:"parent_tool_use_id,omitempty"`
// Top-level error events can be fatal during startup or recoverable once
// the run has begun; the message is inline rather than in a result event.
Error string `json:"error,omitempty"`
// result fields — CodeBuddy failures use is_error + errors/errors_info;
// there is no Claude-style terminal_reason=prompt_too_long signal in the
// shipped @tencent-ai/codebuddy-code 2.150.0 protocol.
ResultText string `json:"result,omitempty"`
IsError bool `json:"is_error,omitempty"`
DurationMs float64 `json:"duration_ms,omitempty"`
NumTurns int `json:"num_turns,omitempty"`
Usage *codebuddyUsage `json:"usage,omitempty"`
ModelUsage map[string]codebuddyResultModelUsage `json:"modelUsage,omitempty"`
// log fields
Log *codebuddyLogEntry `json:"log,omitempty"`
// control request fields
RequestID string `json:"request_id,omitempty"`
Request json.RawMessage `json:"request,omitempty"`
}
type codebuddyLogEntry struct {
Level string `json:"level"`
Message string `json:"message"`
}
type codebuddyMessageContent struct {
ID string `json:"id"`
Role string `json:"role"`
Model string `json:"model"`
Content []codebuddyContentBlock `json:"content"`
Usage *codebuddyUsage `json:"usage,omitempty"`
}
type codebuddyUsage struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
}
type codebuddyResultModelUsage struct {
InputTokens int64 `json:"inputTokens"`
OutputTokens int64 `json:"outputTokens"`
CacheReadInputTokens int64 `json:"cacheReadInputTokens"`
CacheCreationInputTokens int64 `json:"cacheCreationInputTokens"`
}
type codebuddyContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Content json.RawMessage `json:"content,omitempty"`
}
type codebuddyControlRequestPayload struct {
Subtype string `json:"subtype"`
ToolName string `json:"tool_name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
}
func codebuddyResultUsage(msg codebuddySDKMessage, fallbackModel string) map[string]TokenUsage {
if len(msg.ModelUsage) > 0 {
usage := make(map[string]TokenUsage, len(msg.ModelUsage))
for model, u := range msg.ModelUsage {
if model == "" || !codebuddyUsageHasTokens(u.InputTokens, u.OutputTokens, u.CacheReadInputTokens, u.CacheCreationInputTokens) {
continue
}
usage[model] = TokenUsage{
InputTokens: u.InputTokens,
OutputTokens: u.OutputTokens,
CacheReadTokens: u.CacheReadInputTokens,
CacheWriteTokens: u.CacheCreationInputTokens,
}
}
if len(usage) > 0 {
return usage
}
}
model := msg.Model
if model == "" {
model = fallbackModel
}
if msg.Usage == nil || model == "" || !codebuddyUsageHasTokens(
msg.Usage.InputTokens,
msg.Usage.OutputTokens,
msg.Usage.CacheReadInputTokens,
msg.Usage.CacheCreationInputTokens,
) {
return nil
}
return map[string]TokenUsage{
model: {
InputTokens: msg.Usage.InputTokens,
OutputTokens: msg.Usage.OutputTokens,
CacheReadTokens: msg.Usage.CacheReadInputTokens,
CacheWriteTokens: msg.Usage.CacheCreationInputTokens,
},
}
}
func codebuddyUsageHasTokens(input, output, cacheRead, cacheWrite int64) bool {
return input > 0 || output > 0 || cacheRead > 0 || cacheWrite > 0
}