Skip to content
Open
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
2 changes: 1 addition & 1 deletion .upstreamer/coverage-floor.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
72.0
74.0
837 changes: 544 additions & 293 deletions .upstreamer/eval-report.md

Large diffs are not rendered by default.

53 changes: 51 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ Additional consumers include `Response`, `FullResponsesStream`, `ReasoningStream
- Generator tools use `Generate` and emit preliminary events through a yield callback before returning the final output.
- Manual tools set `Manual: true` or omit executable callbacks; they are surfaced as pending calls instead of auto-executed.
- HITL tools use `OnToolCalled`; returning `proceed=false` pauses the agent with pending calls. `OnResponseReceived` rewrites fresh human-supplied tool outputs before the next Responses request.
- Server tools wrap SDK `components.ResponsesRequestToolUnion` values and are passed through to OpenRouter.
- Server tools wrap SDK `components.ResponsesRequestToolUnion` values and are passed through to OpenRouter. `ServerToolConfig.ID` overrides the default `server:<Name>` tool-set id.

`ToolConfig.Strict` is forwarded verbatim to the wire tool definition (omitted when nil, which is the provider default). `ToolConfig.NextTurnParams` computes request parameters for the turn that follows the tool's execution — `toolChoice`, `model`, `models`, `input`, `temperature`, `maxOutputTokens`, `topP`, `topK`, `instructions` — which is how a tool-search tool widens an `allowed_tools` choice without touching the `tools` array, keeping the provider's prompt-cache prefix intact. `ToolConfig.LoopKey` declares the tool's doom-loop call identity.

Tool input schemas are generated from Go structs with `invopop/jsonschema`, sanitized to remove upstream-internal `~` keys, and checked before execution. Dynamic `map[string]any` remains available at JSON boundaries.

Expand All @@ -84,15 +86,56 @@ Use `CreateInitialState`, `AppendToMessages`, `UpdateState`, `PartitionToolCalls

Use `SerializeConversationState` / `DeserializeConversationState` for a versioned, durable-storage-friendly encoding of `ConversationState` (`ConversationStateVersion`). A version mismatch returns `*UnsupportedStateVersionError`; a malformed blob returns `*InvalidStateError` — callers get an explicit error instead of a silently misinterpreted state.

## Tool Sets And `ActiveTools`

`CallModelInput.ActiveTools` narrows which of `Tools` are sent to the model for a given call, addressed by client-tool name (server tools always pass). `CreateToolSet` builds a declarative activation layer on top of it:

```go
set := agent.MustCreateToolSet(agent.ToolSetOptions{Tools: []agent.Tool{listOrders, search}})
set, _ = set.Deactivate("list_orders")
set, _ = set.ActivateWhen("refund", func(in agent.ActivationInput) bool {
return in.Context["admin"] == true
})

snapshot := set.Resolve(agent.ActivationInput{Context: map[string]any{"admin": true}})
result, err := agent.CallModel(ctx, client, snapshot.Apply(agent.CallModelInput{
Model: "openai/gpt-4o-mini",
Input: "Issue the refund.",
}))
```

Mutators are immutable by default: each returns a new `ToolSet` and leaves the receiver untouched. Pass `Mutable: true` to mutate in place. `DefineSituations` registers named partition overlays resolved with `ResolveSituation`. The snapshot also carries `Enabled`, `Disabled` and an exhaustive `StatusByTool` map explaining every decision.

## Doom-Loop Detection

Opt in with `CallModelInput.DoomLoop` (`true` for the defaults, or a `DoomLoopConfig`) to catch runs that stop making progress while continuing to spend — the model re-issuing identical tool calls round after round, repeating server-tool requests, or looping the same text. Detection is deterministic, and responds through a graduated ladder: `observe` → `steer` → `escalate` → `block` → `stop`, defaulting to observe at 2 consecutive identical rounds, block at 3 and stop at 6.

```go
result, err := agent.CallModel(ctx, client, agent.CallModelInput{
Model: "openai/gpt-4o-mini",
Input: "Summarize these files.",
Tools: []agent.Tool{readTool},
DoomLoop: true,
})
// after the run: nil unless detection stopped it
verdict := result.DoomLoopVerdict(ctx)
```

A repeated *fan-out* counts as one unit — `read(a), read(b), read(c)` reissued verbatim is a repeat, and at the block rung every call of the round is refused — while a round that adds new work always executes the new call. A single call repeating inside changing company is caught by its own per-call streak. Declare a tool exempt with `LoopKey: &agent.LoopKey{Exempt: true}`, narrow its identity with `Fields`, or compute it with `Fn` (returning `nil` exempts one call).

Detector state persists in `ConversationState.DoomLoop`, so streaks survive serialize → resume; a `stop` verdict survives approve/reject resumes and clears on a fresh conversational turn. Fingerprints are RFC 8785 (JCS) canonical JSON hashed with SHA-256, byte-compatible with the TypeScript and Python ports. The `DoomLoopDetected` hook fires at every rung — including `observe`, so you can watch without changing behavior — and may override the action.

## Stop Conditions

Use `StepCountIs`, `HasToolCall`, `MaxTokensUsed`, `MaxCost`, `FinishReasonIs`, and `IsStopConditionMet`. Multiple stop conditions are ORed, matching the TypeScript package. `MaxTokensUsed` compares cumulative `total_tokens` only.

A caller-configured *forced* `tool_choice` (`required`, a specific tool, or `allowed_tools` with `mode: "required"`) relaxes to `auto` on follow-up turns once it has actually produced a tool call, including after an approval, HITL or client-tool pause, so the model can synthesize a final answer instead of being forced to call tools until the step budget runs out. `allowed_tools` keeps its tool set and only loses `mode: "required"`. A choice whose semantic value changes re-arms.

`AllowFinalResponse` is **default-on**: when a stop condition halts the loop mid-tool-call, go-agent executes the pending tool calls and issues one more request with `tool_choice: "none"` (tools stay in the request so the prompt-cache prefix survives) so the run ends with a natural-language answer. Omitting the option, or setting it to `true`, appends `agent.DefaultFinalResponseDirective` as a final user message; a non-empty string overrides that wording; `""` forbids tool calls without appending any message; `false` disables the forced final turn entirely.

## Lifecycle Hooks

`HooksManager` (`agent.NewHooksManager`) supports the nine built-in lifecycle hooks — `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `UserPromptSubmit`, `Stop`, `PermissionRequest`, `SessionStart`, `SessionEnd`, and `PostModelCall` — plus fully custom hooks via the generic `agent.On`/`agent.Emit`. Register handlers with the typed `OnXxx` methods (e.g. `manager.OnPreToolUse(...)`) and pass the manager on `CallModelInput.Hooks`:
`HooksManager` (`agent.NewHooksManager`) supports the ten built-in lifecycle hooks — `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `UserPromptSubmit`, `Stop`, `PermissionRequest`, `SessionStart`, `SessionEnd`, `PostModelCall`, and `DoomLoopDetected` — plus fully custom hooks via the generic `agent.On`/`agent.Emit`. Register handlers with the typed `OnXxx` methods (e.g. `manager.OnPreToolUse(...)`) and pass the manager on `CallModelInput.Hooks`:

```go
hooks := agent.NewHooksManager()
Expand All @@ -111,10 +154,16 @@ result, err := agent.CallModel(ctx, client, agent.CallModelInput{

`SessionStart`/`SessionEnd` fire once per non-resuming run (`SessionEnd` carries aggregated token usage across that run's model calls); an approval/HITL resume call is a continuation of the same session and does not get its own pair. `PostModelCall` fires once per model response, tagged `initial`/`resume`/`tool_round`/`final`/`retry`. `PreToolUse`/`PostToolUse`/`PostToolUseFailure` fire around every client-tool execution path, including during a resume. `PermissionRequest` fires before the human-approval pause and can `allow`/`deny`/`ask_user` (default) a gated call. `Stop` fires whenever a stop condition halts the loop mid-tool-call and can force a resume and/or inject a prompt. `UserPromptSubmit` fires once per non-resuming run against the initial user input and can mutate or reject it. Session identity is threaded per emit, so one `HooksManager` is safe to share across concurrent `CallModel` runs. Call `manager.Drain()` to await fire-and-forget handler work; go-agent always drains on every exit path, including no-tools error paths.

## Aggregate Usage

`result.Usage(ctx)` reports token and cost totals across **every** model call a run made — the initial request, each tool-round follow-up, the empty-final retry, the forced final turn, and approval-resume requests. `Response(ctx)` resolves to the final round's response only, so intermediate tool-round generations are otherwise unreachable. It returns the same `SessionUsageTotals` shape as the `SessionEnd` hook, is accumulated independently of the hook system, and never returns an error.

## Format Compatibility

`ToClaudeMessage` / `FromClaudeMessages` and `ToChatMessage` / `FromChatMessages` convert between OpenRouter Responses output and Claude or Chat-style messages. Unsupported content is carried with the structured `original_type`, `data`, and `reason` shape so it can round-trip without being silently lost.

## Notes

The TypeScript package re-exports `SDKHooks`; the Go SDK keeps hooks in an internal package, so go-agent adapts the same intent with `OpenRouterOptions` middleware installed through `openrouter.WithClient`. This keeps request and response interception working for Responses calls without importing internal SDK packages.

Upstream's async tool support (the unified `run()` interface with `background`/`deferred` lifecycles, the universal `task` tool, steering, and subagent tools) is not yet ported. Every tool here executes synchronously within its round, which is upstream's default lifecycle. See `upstreamer-changelog.md`.
19 changes: 15 additions & 4 deletions async_params.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@ import (
type DynamicValue[T any] func(context.Context, TurnContext) (T, error)

type CallModelInput struct {
Model string
ModelFunc DynamicValue[string]
Input any
Tools []Tool
Model string
ModelFunc DynamicValue[string]
Input any
Tools []Tool
// ActiveTools narrows which of Tools are sent to the model for this call,
// addressed by tool-set id (see ToolSetIDOf). nil means "send them all";
// an explicitly empty non-nil slice sends none. The Tools array itself is
// unchanged, so a tool that is filtered out this turn can still be
// resolved when a persisted call for it comes back.
ActiveTools []string
StopWhen []StopCondition
AllowFinalResponse any
// StrictFinalResponse: when true, skips the one-shot retry that would
Expand Down Expand Up @@ -41,6 +47,11 @@ type CallModelInput struct {
// Hooks accepts either a *HooksManager or an InlineHookConfig. See
// ResolveHooks. nil means no hooks.
Hooks any
// DoomLoop opts into doom-loop detection (upstream #73/#89). Accepts
// `true` for the defaults or a DoomLoopConfig to tune the ladder, the
// text detectors and escalation recovery; nil and `false` leave detection
// off. See ResolveDoomLoopOption.
DoomLoop any
}

type CallModelInputWithState = CallModelInput
Expand Down
Loading
Loading