Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
7633543
bug(vercel): first reasoning chunk is dropped for unknown formats
taciturnaxolotl Jun 16, 2026
47c7123
fix(agent): use last text step as AgentResult.Response
taciturnaxolotl Jun 15, 2026
04233bd
v0.31.1
taciturnaxolotl Jun 16, 2026
5c9015a
feat(openai): support max reasoning effort
Amolith Jun 17, 2026
c62e31b
Merge pull request #285 from charmbracelet/add-reasoning-effort-max
Amolith Jun 17, 2026
59d8f7a
fix(vercel,openrouter): emit empty ReasoningStart for streaming reaso…
taciturnaxolotl Jun 17, 2026
fafadf5
v0.32.0
taciturnaxolotl Jun 17, 2026
13774e9
feat(openai): expose extra usage fields in ProviderMetadata
taciturnaxolotl Jun 1, 2026
a13a1b3
v0.33.0
taciturnaxolotl Jun 19, 2026
6b4e9b2
fix(openai): allow tools and content in the same delta
taciturnaxolotl Jun 19, 2026
5bcdd5b
fix(openai): make latest azure gpt models work (#288)
dustinlagoy Jun 19, 2026
5ff5101
fix(openai): defer streaming tool call finalization to end-of-stream
taciturnaxolotl Jun 19, 2026
f018b24
fix: include available tool names in "tool not found" errors (#290)
andreynering Jun 19, 2026
e057614
fix(google): omit function call id for vertex ai to prevent error 400…
eswar-7116 Jun 19, 2026
7dacdda
v0.33.1
andreynering Jun 19, 2026
0c784b8
chore(deps): bump the all group with 2 updates (#291)
dependabot[bot] Jun 22, 2026
5d47177
chore(deps): bump github.com/ardanlabs/kronk in the kronk group (#292)
dependabot[bot] Jun 22, 2026
107d612
fix(openai): keep parallel tool calls separate while streaming
taciturnaxolotl Jun 22, 2026
49edd14
v0.33.2
taciturnaxolotl Jun 22, 2026
81aa4e8
fix(openai): don't double marshel responses api
taciturnaxolotl Jun 22, 2026
4816e03
ci: re-record some vcr cassettes
andreynering Jun 24, 2026
cb0b978
chore(deps): update kronk (#296)
ardan-bkennedy Jun 24, 2026
ceb45c4
fix(google): prevent parallel tool calls from dropping thought_signat…
Cidan Jun 25, 2026
629b6ad
feat(vercel): add vercel context limit error parsing (#299)
andreynering Jun 26, 2026
0cfad67
v0.34.0
andreynering Jun 26, 2026
34ed8e7
chore(deps): bump github.com/ardanlabs/kronk in the kronk group (#301)
dependabot[bot] Jun 29, 2026
6cb700a
chore(deps): bump the all group with 2 updates (#300)
dependabot[bot] Jun 29, 2026
bfdb4ad
feat: enable prompt caching for OpenAI-compatible models that need pe…
taciturnaxolotl Jun 30, 2026
59d70ca
v0.35.0
taciturnaxolotl Jun 30, 2026
ff13760
chore(deps): bump the all group with 3 updates (#306)
dependabot[bot] Jul 6, 2026
213d6e2
chore(deps): bump github.com/ardanlabs/kronk (#307)
dependabot[bot] Jul 6, 2026
4ab0142
v0.35.1
andreynering Jul 7, 2026
263de1d
ci: fix govulncheck
andreynering Jul 8, 2026
29b1da1
chore: prepare for gpt-5.6 (#308)
andreynering Jul 8, 2026
4f5ec5a
ci: only run lint on ubuntu
andreynering Jul 8, 2026
49274e4
v0.35.2
andreynering Jul 8, 2026
f78b5d6
feat: support per-call HTTP headers
meowgorithm Jul 9, 2026
8563ff4
v0.36.0
meowgorithm Jul 9, 2026
d666763
feat: unauthorized hook (#295)
taciturnaxolotl Jul 13, 2026
b212c87
v0.37.0
taciturnaxolotl Jul 13, 2026
d4fdb9b
fix: copy ModelProvider when deepcopying
taciturnaxolotl Jul 14, 2026
bc2ff36
v0.37.1
taciturnaxolotl Jul 14, 2026
6504cf7
chore: switch back to upstream openai sdk (#313)
andreynering Jul 15, 2026
b4af693
chore(deps): bump the all group across 1 directory with 3 updates (#311)
dependabot[bot] Jul 15, 2026
4127293
v0.37.2
andreynering Jul 15, 2026
92ff563
chore(deps): upgrading kronk (#315)
ardan-bkennedy Jul 15, 2026
ec2dfd0
fix: increase default retry backoff to 5s and 3 max retries (#314)
andreynering Jul 15, 2026
720ab8b
v0.37.3
andreynering Jul 15, 2026
f8664d5
feat(stream): catch transport errors and retry (#312)
taciturnaxolotl Jul 16, 2026
c90637f
v0.38.0
taciturnaxolotl Jul 16, 2026
09bfacc
fix: include bedrock auth errors
taciturnaxolotl Jul 16, 2026
5b5ac14
v0.38.1
taciturnaxolotl Jul 16, 2026
1f73dea
fix: gosec lint warning
taciturnaxolotl Jul 16, 2026
ca86309
chore(deps): bump the all group with 3 updates (#317)
dependabot[bot] Jul 20, 2026
d0dac41
chore(deps): bump github.com/ardanlabs/kronk (#318)
dependabot[bot] Jul 20, 2026
33bcef7
chore(deps): bump the all group with 4 updates (#321)
dependabot[bot] Jul 27, 2026
7f72ca6
chore(deps): bump github.com/ardanlabs/kronk (#322)
dependabot[bot] Jul 27, 2026
070e912
fix(anthropic): map refusal and context-window stop reasons (#316)
sven2718 Jul 29, 2026
02c02ef
v0.38.2
andreynering Jul 29, 2026
0d01548
feat: add ClientMetadata to ToolResultPart (#258)
carsonfarmer Jul 29, 2026
435d550
chore(deps) update kronk to v1.29.9 (#324)
ardan-bkennedy Jul 31, 2026
e5c89d1
fix(providers/openaicompat): forward tool result media to LLM (#326)
taciturnaxolotl Jul 31, 2026
7ee11fd
fix(openai): classify in-band SSE stream errors for retry (#319)
albatrossflyon-coder Jul 31, 2026
c8247d5
fix(agent): convert tool panics into failed tool results (#325)
taciturnaxolotl Jul 31, 2026
6e4c7f8
v0.39.0
taciturnaxolotl Jul 31, 2026
7f214ef
feat: add the bedrock specific refusals
taciturnaxolotl Jul 31, 2026
58d4a10
v0.40.0
taciturnaxolotl Jul 31, 2026
f044a42
chore(deps): bump the all group with 3 updates (#328)
dependabot[bot] Aug 3, 2026
042aafc
chore(deps): bump the all group with 6 updates (#334)
dependabot[bot] Aug 10, 2026
d37f122
chore: sync upstream charmbracelet/fantasy main (v0.40.0) into coder_…
ibetitsmike Aug 10, 2026
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
2 changes: 2 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: lint
on:
push:
branches: [main]
pull_request:

jobs:
Expand All @@ -10,3 +11,4 @@ jobs:
golangci_path: .golangci.yml
golangci_version: v2.9
timeout: 10m
os: '["ubuntu-latest"]'
131 changes: 123 additions & 8 deletions agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import (
"errors"
"fmt"
"maps"
"runtime/debug"
"slices"
"strings"
"sync"

"charm.land/fantasy/jsonrepair"
Expand Down Expand Up @@ -171,10 +173,18 @@ type AgentCall struct {
FrequencyPenalty *float64 `json:"frequency_penalty"`
ActiveTools []string `json:"active_tools"`
ToolChoice *ToolChoice `json:"tool_choice"`
Headers map[string]string
ProviderOptions ProviderOptions
OnRetry OnRetryCallback
OnAuthRefresh OnAuthRefreshFunc
MaxRetries *int

// ModelProvider, when non-nil, is called on each retry attempt to
// obtain the language model. This allows callers to swap in a
// refreshed model after OnAuthRefresh rebuilds credentials. When
// nil, the model captured at step preparation time is used.
ModelProvider func() LanguageModel

StopWhen []StopCondition
PrepareStep PrepareStepFunction
RepairToolCall RepairToolCallFunction
Expand All @@ -199,6 +209,16 @@ type (

// OnErrorFunc is called when an error occurs.
OnErrorFunc func(error)

// OnAuthRefreshFunc is called when a stream fails with an authentication
// error that the caller may be able to resolve (e.g. an expired SSO
// session or OAuth token). The function should perform whatever credential
// refresh is needed and return nil on success, in which case fantasy
// retries the operation transparently. Returning an error surfaces the
// original auth error to the caller without retry. Pair this with
// ModelProvider to supply a rebuilt model carrying the refreshed
// credentials on the retry attempt.
OnAuthRefreshFunc func(ctx context.Context, err *ProviderError) error
)

// Stream part callbacks - called for each corresponding stream part type.
Expand Down Expand Up @@ -265,8 +285,15 @@ type AgentStreamCall struct {
Headers map[string]string
ProviderOptions ProviderOptions
OnRetry OnRetryCallback
OnAuthRefresh OnAuthRefreshFunc
MaxRetries *int

// ModelProvider, when non-nil, is called on each retry attempt to
// obtain the language model. This allows callers to swap in a
// refreshed model after OnAuthRefresh rebuilds credentials. When
// nil, the model captured at step preparation time is used.
ModelProvider func() LanguageModel

StopWhen []StopCondition
PrepareStep PrepareStepFunction
RepairToolCall RepairToolCallFunction
Expand Down Expand Up @@ -300,11 +327,44 @@ type AgentStreamCall struct {
// AgentResult represents the result of an agent execution.
type AgentResult struct {
Steps []StepResult
// Final response
// Final response. When the last step is tool-only (no text content),
// this is the response from the most recent step that contained text,
// so callers always see meaningful output without walking Steps manually.
Response Response
TotalUsage Usage
}

// finalResponse picks the best Response from a slice of steps. It walks
// backwards to find the most recent step with non-blank text content. If no
// step has text content (e.g. all steps were tool calls), the last step's
// response is returned as-is.
func finalResponse(steps []StepResult) Response {
for i := len(steps) - 1; i >= 0; i-- {
if hasNonBlankText(steps[i].Content) {
return steps[i].Response
}
}
if len(steps) > 0 {
return steps[len(steps)-1].Response
}
return Response{}
}

// hasNonBlankText reports whether content contains at least one text block
// with non-whitespace characters.
func hasNonBlankText(content ResponseContent) bool {
for _, c := range content {
if c.GetType() == ContentTypeText {
if tc, ok := AsContentType[TextContent](c); ok {
if strings.TrimSpace(tc.Text) != "" {
return true
}
}
}
}
return false
}

// Agent represents an AI agent that can generate responses and stream responses.
type Agent interface {
Generate(context.Context, AgentCall) (*AgentResult, error)
Expand Down Expand Up @@ -368,6 +428,10 @@ func (a *agent) prepareCall(call AgentCall) AgentCall {
if a.settings.headers != nil {
maps.Copy(headers, a.settings.headers)
}
if call.Headers != nil {
maps.Copy(headers, call.Headers)
}
call.Headers = headers

return call
}
Expand Down Expand Up @@ -452,9 +516,17 @@ func (a *agent) Generate(ctx context.Context, opts AgentCall) (*AgentResult, err
retryOptions.MaxRetries = *opts.MaxRetries
}
retryOptions.OnRetry = opts.OnRetry
retryOptions.OnAuthRefresh = opts.OnAuthRefresh
retry := RetryWithExponentialBackoffRespectingRetryHeaders[*Response](retryOptions)
result, err := retry(ctx, func() (*Response, error) {
return stepModel.Generate(ctx, Call{
// Re-read the model on each retry attempt so that
// OnAuthRefresh can swap in a model with fresh credentials.
retryModel := stepModel
if opts.ModelProvider != nil {
retryModel = opts.ModelProvider()
}

return retryModel.Generate(ctx, Call{
Prompt: stepInputMessages,
MaxOutputTokens: opts.MaxOutputTokens,
Temperature: opts.Temperature,
Expand All @@ -465,6 +537,7 @@ func (a *agent) Generate(ctx context.Context, opts AgentCall) (*AgentResult, err
Tools: preparedTools,
ToolChoice: &stepToolChoice,
UserAgent: a.settings.userAgent,
Headers: opts.Headers,
ProviderOptions: opts.ProviderOptions,
})
})
Expand Down Expand Up @@ -555,7 +628,7 @@ func (a *agent) Generate(ctx context.Context, opts AgentCall) (*AgentResult, err

agentResult := &AgentResult{
Steps: steps,
Response: steps[len(steps)-1].Response,
Response: finalResponse(steps),
TotalUsage: totalUsage,
}
return agentResult, nil
Expand Down Expand Up @@ -643,6 +716,7 @@ func toResponseMessages(content []Content) []Message {
Output: result.Result,
ProviderExecuted: result.ProviderExecuted,
ProviderOptions: ProviderOptions(result.ProviderMetadata),
ClientMetadata: result.ClientMetadata,
}
if result.ProviderExecuted {
// Provider-executed tool results (e.g. web search)
Expand Down Expand Up @@ -740,8 +814,11 @@ func (a *agent) executeSingleTool(ctx context.Context, toolMap map[string]AgentT
return result, false
}

// Execute the tool
toolResult, err := runTool(ctx, ToolCall{
// Execute the tool, converting a panic into a failed tool result so a
// single misbehaving tool cannot take down the whole process. The panic
// value and stack are included so they survive in the transcript even
// when stderr is lost.
toolResult, err := runToolSafely(ctx, runTool, ToolCall{
ID: toolCall.ToolCallID,
Name: toolCall.ToolName,
Input: toolCall.Input,
Expand Down Expand Up @@ -781,6 +858,25 @@ func (a *agent) executeSingleTool(ctx context.Context, toolMap map[string]AgentT
return result, false
}

// runToolSafely invokes a tool's run function and converts any panic into a
// failed tool result. Tool implementations run on goroutines spawned by the
// agent's step processor where an unrecovered panic would crash the entire
// host process, so this boundary is the last line of defense. The panic
// value and stack trace are captured in the returned error so they are
// preserved in the conversation transcript and any persisted logs.
func runToolSafely(ctx context.Context, runTool func(ctx context.Context, call ToolCall) (ToolResponse, error), call ToolCall) (toolResult ToolResponse, err error) {
defer func() {
if r := recover(); r != nil {
stack := debug.Stack()
toolResult = NewTextErrorResponse(fmt.Sprintf(
"tool %q panicked: %v\n\n%s", call.Name, r, stack,
))
err = nil
}
}()
return runTool(ctx, call)
}

// Stream implements Agent.
func (a *agent) Stream(ctx context.Context, opts AgentStreamCall) (*AgentResult, error) {
// Convert AgentStreamCall to AgentCall for preparation
Expand All @@ -796,9 +892,12 @@ func (a *agent) Stream(ctx context.Context, opts AgentStreamCall) (*AgentResult,
FrequencyPenalty: opts.FrequencyPenalty,
ActiveTools: opts.ActiveTools,
ToolChoice: opts.ToolChoice,
Headers: opts.Headers,
ProviderOptions: opts.ProviderOptions,
MaxRetries: opts.MaxRetries,
OnRetry: opts.OnRetry,
OnAuthRefresh: opts.OnAuthRefresh,
ModelProvider: opts.ModelProvider,
StopWhen: opts.StopWhen,
PrepareStep: opts.PrepareStep,
RepairToolCall: opts.RepairToolCall,
Expand Down Expand Up @@ -900,6 +999,7 @@ func (a *agent) Stream(ctx context.Context, opts AgentStreamCall) (*AgentResult,
Tools: preparedTools,
ToolChoice: &stepToolChoice,
UserAgent: a.settings.userAgent,
Headers: call.Headers,
ProviderOptions: call.ProviderOptions,
}

Expand All @@ -909,11 +1009,19 @@ func (a *agent) Stream(ctx context.Context, opts AgentStreamCall) (*AgentResult,
retryOptions.MaxRetries = *call.MaxRetries
}
retryOptions.OnRetry = call.OnRetry
retryOptions.OnAuthRefresh = call.OnAuthRefresh
retry := RetryWithExponentialBackoffRespectingRetryHeaders[stepExecutionResult](retryOptions)

result, err := retry(ctx, func() (stepExecutionResult, error) {
// Re-read the model on each retry attempt so that
// OnAuthRefresh can swap in a model with fresh credentials.
retryModel := stepModel
if call.ModelProvider != nil {
retryModel = call.ModelProvider()
}

// Create the stream
stream, err := stepModel.Stream(ctx, streamCall)
stream, err := retryModel.Stream(ctx, streamCall)
if err != nil {
return stepExecutionResult{}, err
}
Expand Down Expand Up @@ -954,7 +1062,7 @@ func (a *agent) Stream(ctx context.Context, opts AgentStreamCall) (*AgentResult,
// Finish agent stream
agentResult := &AgentResult{
Steps: steps,
Response: steps[len(steps)-1].Response,
Response: finalResponse(steps),
TotalUsage: totalUsage,
}

Expand Down Expand Up @@ -1088,7 +1196,14 @@ func (a *agent) validateToolCall(toolCall ToolCallContent, availableTools []Agen
return nil
}
}
return fmt.Errorf("tool not found: %s", toolCall.ToolName)
names := make([]string, 0, len(availableTools)+len(execProviderTools))
for _, t := range availableTools {
names = append(names, t.Info().Name)
}
for _, ept := range execProviderTools {
names = append(names, ept.GetName())
}
return fmt.Errorf("tool not found: %s. Available tools: %s", toolCall.ToolName, strings.Join(names, ", "))
}

// Validate JSON parsing
Expand Down
Loading
Loading