Files
Agent-Native/runtime/llm/client.go
T
weichenandCursor fb2b3b796c
ci / vet-and-test (pull_request) Successful in 46s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Record tool intent before execution and add file checkpoints
Why: a crash after a tool was about to run left the call unpaired, and workspace writes could not be rewound.
Evidence: GOWORK=off go test ./runtime/ ./runtime/llm/ ./runtime/checkpoint/ ./runtime/telemetry/ ./runtime/tools/ ./runtime/session/ ./adapters/native/
Impact: pending calls rerun only when both the stored and current replay declarations are safe; otherwise the result is an error. Empty fallback lists and a nil checkpoint log keep the previous behavior.
Refs: feature/durable-rewind
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 17:27:04 +00:00

168 lines
4.4 KiB
Go

package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Client calls an OpenAI-compatible chat completions endpoint with streaming.
type Client struct {
BaseURL string
APIKey string
HTTPClient *http.Client
}
// Stream implements StreamFunc. Transport and provider failures are encoded as
// KindError events; the returned channel is always non-nil and is closed.
func (c *Client) Stream(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
out := make(chan Event)
go func() {
defer close(out)
c.run(ctx, model, messages, tools, opts, out)
}()
return out
}
func (c *Client) run(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions, out chan<- Event) {
base := strings.TrimRight(c.BaseURL, "/")
if opts.APIBase != "" {
base = strings.TrimRight(opts.APIBase, "/")
}
key := c.APIKey
if opts.APIKey != "" {
key = opts.APIKey
}
if base == "" || key == "" || model == "" {
emitError(out, "", false, "agentx llm client is missing base url, api key, or model")
return
}
body := map[string]any{
"model": model,
"messages": toProviderMessages(messages),
"stream": true,
"stream_options": map[string]any{
"include_usage": true,
},
}
if opts.MaxTokens > 0 {
body["max_tokens"] = opts.MaxTokens
}
if effort := OpenAIReasoningEffort(opts.ThinkingLevel); effort != "" {
body["reasoning_effort"] = effort
}
if len(tools) > 0 {
body["tools"] = toProviderTools(tools)
body["tool_choice"] = "auto"
}
raw, err := json.Marshal(body)
if err != nil {
emitError(out, "", false, err.Error())
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/chat/completions", bytes.NewReader(raw))
if err != nil {
emitError(out, "", false, err.Error())
return
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
hc := c.HTTPClient
if hc == nil {
hc = &http.Client{Timeout: 30 * time.Minute}
}
resp, err := hc.Do(req)
if err != nil {
if ctx.Err() != nil {
emitAbort(out, "", ctx.Err())
return
}
emitError(out, "", true, err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
slurp, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
msg := fmt.Sprintf("provider status %d: %s", resp.StatusCode, strings.TrimSpace(string(slurp)))
ev := Event{
Kind: KindError,
HTTPStatus: resp.StatusCode,
Retryable: StatusRetryable(resp.StatusCode) && !ContextOverflow(msg),
ErrorMessage: msg,
Err: &StreamError{Message: msg, Retryable: StatusRetryable(resp.StatusCode) && !ContextOverflow(msg)},
Message: Message{Role: RoleAssistant, StopReason: StopError},
}
if d, ok := ParseRetryAfter(resp.Header.Get("Retry-After"), time.Now()); ok {
ev.HasRetryAfter = true
ev.RetryAfter = d
}
out <- ev
return
}
for ev := range ParseSSE(ctx, resp.Body) {
out <- ev
}
}
func toProviderMessages(messages []Message) []map[string]any {
out := make([]map[string]any, 0, len(messages))
for _, m := range messages {
switch m.Role {
case RoleTool:
out = append(out, map[string]any{
"role": "tool",
"tool_call_id": m.ToolCallID,
"content": m.Content,
})
case RoleAssistant:
item := map[string]any{"role": "assistant", "content": m.Content}
if len(m.ToolCalls) > 0 {
calls := make([]map[string]any, 0, len(m.ToolCalls))
for _, tc := range m.ToolCalls {
calls = append(calls, map[string]any{
"id": tc.ID,
"type": "function",
"function": map[string]any{
"name": tc.Name,
"arguments": tc.Arguments,
},
})
}
item["tool_calls"] = calls
}
out = append(out, item)
default:
out = append(out, map[string]any{
"role": string(m.Role),
"content": messageContent(m),
})
}
}
return out
}
func toProviderTools(tools []ToolDecl) []map[string]any {
out := make([]map[string]any, 0, len(tools))
for _, t := range tools {
params := t.Parameters
if len(params) == 0 {
params = json.RawMessage(`{"type":"object","properties":{}}`)
}
out = append(out, map[string]any{
"type": "function",
"function": map[string]any{
"name": t.Name,
"description": t.Description,
"parameters": json.RawMessage(params),
},
})
}
return out
}