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
15 changes: 14 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,12 @@ depending on `internal/model` or duplicating provider wiring.
`model.OpenAIClientConfig` controls whether the OpenAI Responses adapter asks
for and forwards provider-authored reasoning summaries. It is disabled by
default; encrypted reasoning content remains included for stateless turn
continuation regardless of that display setting.
continuation regardless of that display setting or response-storage policy.

The same configuration independently controls response storage through
`DisableModelResponseStore`. Every OpenAI Complete and Summarize request sends
`store` explicitly. Storage defaults to enabled and is disabled only when that
field is true.

## Durable Agent model

Expand Down Expand Up @@ -154,6 +159,14 @@ boundary. Waiting state is written in the `WaitFor` that establishes the wait.
Provider and MCP calls occur only in `Execute`. The complete graph and resource
table are in `docs/flow-model.md`.

`WithModelHooks` adds four durable model boundaries without changing the
legacy Step identities. Before admission generates a stable call ID from the
Flow and Step execution. The Call Step performs the provider request and
overwrites the single `ModelUsage` Attribute. The After Step reads that
Attribute and invokes the idempotent application hook; Apply then mutates Agent
history. No accounting totals, provider callback, model checkpoint state, or
model-recovery API exists in SuperAgent.

History-reading Steps declare bounded AttributeMap loads explicitly. Integration
tests use verb-first `ForTestOnly` RPCs rather than generic Dex Client resource
operations. Tool invocations receive the stable Flow ID, model call ID, and
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,25 @@ idempotent application cleanup request. Accepted user mutations coalesce Timer
resets within one minute. A `durable_wait` extends the deadline through its
target; model and ordinary tool work do not pause it.

Embedders that need admission and accounting boundaries can pass
`agent.WithModelHooks(...)`. A non-nil configuration requires both hooks. Dex
runs `BeforeModelCall`, `CallModelWithHooks`, `AfterModelCall`, and
`ApplyModelResult` as separate Steps, so an After retry never repeats the model
call. The Call Step overwrites the `ModelUsage` Attribute with the provider's
exact usage for that call; the After hook receives that same value. SuperAgent
does not accumulate account usage.

```go
flow := agent.NewFlow(modelClient, tools, agent.WithModelHooks(&agent.ModelHooksConfig{
BeforeModelCall: admitModelCall,
AfterModelCall: synchronizeModelUsage,
}))
```

The Before hook returns `false, nil` for a business rejection. Hook errors use
the owning Dex Step retry policy. The After hook must be safely repeatable by
its supplied `CallID`.

External tool retries belong to Dex. `ToolDefinition` controls attempt timeout,
maximum attempts, and total duration. A registry performs exactly one call for
each Dex attempt.
Expand Down Expand Up @@ -152,6 +171,7 @@ available.
| `DEX_BLOB_CACHE_MAX_BYTES` | BlobCache size limit in bytes | `536870912` |
| `DEX_AGENT_MCP_CONFIG` | Trusted MCP YAML path | disabled |
| `OPENAI_API_KEY` | OpenAI credential | unset |
| `OPENAI_DISABLE_MODEL_RESPONSE_STORE` | Send `store=false` for OpenAI Responses calls | `false` |
| `ANTHROPIC_API_KEY` | Anthropic credential | unset |
| `GEMINI_API_KEY` | Gemini credential | unset |
| `GROQ_API_KEY` | Groq credential | unset |
Expand All @@ -162,6 +182,11 @@ persisted in Dex state or logged. Copy
[`web/mcp-servers.example.yaml`](web/mcp-servers.example.yaml) to configure
trusted MCP servers.

OpenAI Responses calls always send `store` explicitly. The default is
`store=true`; set `OPENAI_DISABLE_MODEL_RESPONSE_STORE=true` only when the
deployment requires provider-side response storage to be disabled. This setting
is independent of model hooks.

Each configured tool defaults to `running_type: short_running`. Use
`long_running` when more than half of expected calls exceed five seconds. This
is a Dex placement optimization, not a timeout or SLA; short-running calls may
Expand Down
9 changes: 9 additions & 0 deletions agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,13 +232,17 @@ type (
InputConsumption = agentinternal.InputConsumption
StreamEvent = agentinternal.StreamEvent
ModelReply = agentinternal.ModelReply
ModelUsage = agentinternal.ModelUsage
ToolDefinition = agentinternal.ToolDefinition
ToolExecutionResult = agentinternal.ToolExecutionResult
TextWriter = agentinternal.TextWriter
ActivityWriter = agentinternal.ActivityWriter
ModelRequest = agentinternal.ModelRequest
SummarizeRequest = agentinternal.SummarizeRequest
ModelClient = agentinternal.ModelClient
BeforeModelCallHook = agentinternal.BeforeModelCallHook
AfterModelCallHook = agentinternal.AfterModelCallHook
ModelHooksConfig = agentinternal.ModelHooksConfig
ToolInvocation = agentinternal.ToolInvocation
ToolRegistry = agentinternal.ToolRegistry
RegisteredTool = agentinternal.RegisteredTool
Expand All @@ -257,6 +261,11 @@ func WithInactivityExpirationHandler(handler InactivityExpirationHandler) FlowOp
return agentinternal.WithInactivityExpirationHandler(handler)
}

// WithModelHooks configures optional durable boundaries around model calls.
func WithModelHooks(config *ModelHooksConfig) FlowOption {
return agentinternal.WithModelHooks(config)
}

// NewClient constructs an Agent application client over one Dex client and Flow definition.
func NewClient(sdkClient *dex.Client, flow *Flow) *Client {
return agentinternal.NewClient(sdkClient, flow)
Expand Down
49 changes: 49 additions & 0 deletions docs/adr/0017-optional-model-hooks-and-exact-usage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# ADR 0017: Optional model hooks and exact per-call usage

## Status

Accepted for SuperAgent `v0.7.0`.

## Context

Embedding applications may need to admit model calls against account policy and
synchronize exact provider usage. That policy is application-owned. Putting
accounts, projects, quotas, totals, or recovery state in SuperAgent would couple
the reusable Agent Flow to one control plane.

The model provider response is an external effect. Retrying accounting after a
successful provider call must not repeat that call, and retrying Agent state
application must not repeat accounting.

## Decision

`ModelClient.Complete` and `Summarize` return `ModelUsage` separately from their
existing result. `ModelReply` remains unchanged. Every successful legacy or
hooked Call Step overwrites one `ModelUsage` Attribute; it never accumulates
usage.

`WithModelHooks` is optional and requires both a Before and After hook. Hooked
calls execute as four Dex Steps:

1. `BeforeModelCall` creates a stable call ID and performs admission.
2. `CallModelWithHooks` calls the provider and writes `ModelUsage`.
3. `AfterModelCall` reads that Attribute and synchronizes usage idempotently.
4. `ApplyModelResult` applies the persisted provider result.

The call ID derives from Flow ID, the Before Step execution ID, and call kind.
It is carried in Step input, not an Attribute. OpenAI receives it as its
idempotency key. Hooked calls permit only OpenAI and Mock until other adapters
report exact usage.

OpenAI response storage is an independent client setting. Complete and
Summarize always send `store`; the default is true and
`DisableModelResponseStore` changes it to false.

## Consequences

After retries cannot repeat the provider call, and Apply retries cannot repeat
the After hook. The embedding application owns account lookup, monthly totals,
limits, and idempotency for synchronized usage.

SuperAgent has no usage reporter, accumulated usage Attribute, model checkpoint
state, pending recovery record, model recovery API, or recovery UI.
17 changes: 17 additions & 0 deletions docs/flow-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ retry, and recovery settings. Every regular attempt uses a one-minute heartbeat
timeout. Ordinary Step methods use a one-minute method timeout, while model
methods retain their explicit ten-minute method timeout.

When `WithModelHooks` is absent, `CompactContext` and `CallModel` keep their
existing identities and inputs. When it is present, both model-call kinds use
the four-Step hook path below. Only OpenAI and Mock provide exact usage in this
release, so a hooked Flow rejects other providers before dispatch.

The Worker negotiates the highest common protocol with the Server before
Attribute index synchronization or Worker binding. Deploy the Server before the
Worker. Startup fails when `GetServerInfo` is missing, either interval is
Expand Down Expand Up @@ -64,6 +69,13 @@ CallModel
-> CheckSteered -> AwaitUser (ordinary or repeated no-progress response)
-> CheckSteered -> RouteTool (tool calls)

Hooks-enabled response or compaction
-> BeforeModelCall (admit and create stable CallID)
-> CallModelWithHooks (provider call; overwrite ModelUsage)
-> AfterModelCall (read ModelUsage; synchronize usage)
-> ApplyModelResult (mutate Agent history or summary)
-> existing continuation

RouteTool
-> CheckSteered -> AwaitToolApproval (untrusted write)
-> CheckSteered -> ExecuteTool (approved/read-only external tool)
Expand Down Expand Up @@ -161,6 +173,10 @@ history, and makes the model replan.
| `AnsweredInput` | none | Route an accepted answer through compaction directly to the model before checking steering |
| `CompactContext` | none | Call the summary provider, commit the covered range and summary, then trim only summarized retained messages |
| `CallModel` | none | Rebuild context, stream buffered deltas, commit the assistant message and pending calls; retry one active-plan response that made no durable progress |
| `BeforeModelCall` | none | Resolve the selected model, create the stable call ID, and invoke optional admission |
| `CallModelWithHooks` | none | Call the provider once per Dex attempt, overwrite exact `ModelUsage`, and persist the result in Step output |
| `AfterModelCall` | none | Read `ModelUsage` and invoke the application-owned idempotent synchronization hook |
| `ApplyModelResult` | none | Apply the persisted assistant response or compaction summary without repeating model or hook side effects |
| `CheckSteered` | bounded steered batch | Apply steering at a safe boundary or route the explicit continuation |
| `RouteTool` | none | Validate built-in arguments and select approval, MCP execution, timer, input, or next-call path |
| `AwaitToolApproval` | exact call-ID approval or steering | Persist waiting status; consume one decision or replan on steering |
Expand Down Expand Up @@ -228,6 +244,7 @@ deadline. `DurableWait` advances it to the wait target plus the timeout.
| `AgentConfig` | Attribute | Immutable execution configuration |
| `AgentRuntimeMetadata` | Attribute | Trusted runtime routing metadata; never model or browser context |
| `AgentState` | Attribute | Sequence range, mode, status, plan revision, pending-call cursor, and Plan no-progress count |
| `ModelUsage` | Attribute | Exact usage from the last successful model call; overwritten, never accumulated |
| `WaitingInputRound` | Attribute | Monotonic durable browser watermark, initialized to zero |
| `ContextSummary` | Attribute | Cumulative summary and explicit covered sequence |
| `CurrentMessages` | AttributeMap | Recent provider-neutral messages keyed by sequence |
Expand Down
15 changes: 15 additions & 0 deletions docs/v0.7.0-upgrade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# SuperAgent v0.7.0 upgrade

`ModelClient` implementations must return one additional `ModelUsage` value
from `Complete` and `Summarize`. Existing `ModelReply` values do not change.
Adapters without exact usage may return `ModelUsage{}`; such providers cannot be
used with `WithModelHooks` in this release.

Applications may opt into durable admission and usage synchronization with
`WithModelHooks`. Both hooks are required when the option is non-nil. After hooks
must be idempotent by the supplied stable call ID.

OpenAI now explicitly sends `store=true` by default for both Complete and
Summarize. Set `OpenAIClientConfig.DisableModelResponseStore` or the standalone
process environment variable `OPENAI_DISABLE_MODEL_RESPONSE_STORE=true` to send
`store=false`.
Loading