Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76747394ce | |||
| 26ab5a3bf0 | |||
| d584d372cd | |||
| 521aff145f | |||
| eafa186894 | |||
| 91c57663cc | |||
| cb49c1c2d8 | |||
| db31341b30 | |||
| a583d5741d | |||
| c9e61c0f7b | |||
| 39f8aee34d | |||
| 7f9096a1cd | |||
| b6ad784b67 | |||
| a662bcff9d | |||
| 482d3e7d69 | |||
| 5aa6e50ae5 | |||
| b2369885bb | |||
| 741f308546 | |||
| 5e49464323 |
+29
-19
@@ -1,27 +1,37 @@
|
||||
# Priorities
|
||||
|
||||
The ranked work queue for the autonomous improvement loop. The
|
||||
**architecture-review** pass (the *architect*) owns this file: each run it turns
|
||||
the [roadmap](../../ROADMAP.md) plus an internal scan (gaps in the
|
||||
services → agents → workflows lifecycle, API coherence, drift, tech debt, test and
|
||||
DX friction) into a single ordered list — highest-value first — and links each
|
||||
item to a tracking issue. The hourly **continuous-improvement** pass works the
|
||||
**top item whose issue is still open**. So the architect decides *what*, and the
|
||||
increment loop *builds* it.
|
||||
The ranked work queue for the autonomous improvement loop. The **planner** owns
|
||||
this file: each run it turns the [roadmap](../../ROADMAP.md) plus an internal scan
|
||||
into a single ordered list — highest-value first — each item linked to a tracking
|
||||
issue. The **builder** works the top item whose issue is still open. So the
|
||||
planner decides *what*, the builder *builds* it.
|
||||
|
||||
**Reading / editing.** An item is done when its linked issue closes (the increment
|
||||
that builds it adds `Closes #<issue>`). Roadmap phase (Now → Next → Later) is the
|
||||
primary ordering; internal findings are interleaved by value, not kept in a
|
||||
separate list. The human can reorder this list — or the issues — at any time to
|
||||
redirect the loop; direction always wins.
|
||||
**Bias to capability, not busy-work.** The top of this queue is net-new capability
|
||||
from the roadmap's *Now/Next* items. Hardening/conformance/DX polish is background
|
||||
work (roadmap *Ongoing*) — kept low here and capped, never allowed to crowd out
|
||||
capability. If an area has had several increments with no user-visible gain, it is done
|
||||
for now; rank real-headroom capability instead.
|
||||
|
||||
**Off-limits to the loop** (the architect proposes these as notes, never as queue
|
||||
items the loop can auto-merge): brand/positioning copy, breaking public-API
|
||||
changes, architectural rewrites. Those go to the human.
|
||||
**Reading / editing.** An item is done when its linked issue closes (the PR that
|
||||
builds it adds `Closes #<issue>`). The human can reorder this list or the issues at
|
||||
any time — direction always wins.
|
||||
|
||||
**Off-limits to the loop** (planner proposes as notes, never auto-merged queue
|
||||
items): brand/positioning copy, breaking public-API changes, architectural
|
||||
rewrites.
|
||||
|
||||
## Work queue (ranked)
|
||||
|
||||
1. **Add CLI continuation for input-required agent runs** ([#4755](https://github.com/micro/go-micro/issues/4755)) — #4750 closed the cancellation/deadline propagation gap and there are no open codex PRs in flight. Do not re-queue more docs-link checks, AtlasCloud-specific text repair, or plan/delegate edge hardening for now; those areas have had several recent increments. The next highest-value user-facing gap is making human-in-the-loop pauses operable from the scaffold → run → chat → inspect path: list an `input-required` run, provide the missing input from the CLI, and inspect the completed run without requiring a developer to write a Go resume helper.
|
||||
### Capability — the headline (roadmap: Now / Next)
|
||||
|
||||
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
|
||||
architecture-review pass._
|
||||
1. **Agent spend observability** ([#4787](https://github.com/micro/go-micro/issues/4787)) — surface x402 spend in `RunInfo` and OpenTelemetry so payments are inspectable like every other agent action. (Follows #4786.)
|
||||
2. **Example: an agent that pays for a paid tool** ([#4788](https://github.com/micro/go-micro/issues/4788)) — the runnable artifact that makes it real for a developer, against a mock facilitator (no live funds). (Follows #4786/#4787.)
|
||||
3. **gRPC-reflection MCP** ([#4796](https://github.com/micro/go-micro/issues/4796)) — expose external reflected gRPC services as MCP tools, not only go-micro-native handlers. A large jump in what agents can operate without requiring teams to rewrite existing services.
|
||||
4. **Kubernetes operator + CRDs foundation** ([#4797](https://github.com/micro/go-micro/issues/4797)) — add the first opt-in `Agent`, `Service`, and `Flow` resource foundation so the services → agents → workflows lifecycle has a native deployment path for Kubernetes users.
|
||||
|
||||
### Background — hardening & DX (roadmap: Ongoing; capped)
|
||||
|
||||
_Background hardening is intentionally empty right now. Recent work covered first-agent
|
||||
wayfinding, plan/delegate recovery, provider fallback repair, streaming, memory
|
||||
compaction, retry controls, and provider-failure inspection. Further churn in those
|
||||
areas should be marked `needs-human` unless it unlocks a clear user-visible capability._
|
||||
|
||||
@@ -18,15 +18,46 @@ below is kept current between tags and rolled into the next version when it ship
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Gemini streaming support** — the Gemini provider now supports streaming model responses. (`ai/gemini/`)
|
||||
- **Model retry jitter controls** — model retry behavior can now use jitter controls to reduce synchronized retry bursts. (`ai/`, `agent/`)
|
||||
- **Compacted memory summaries** — agent memory now exposes compacted run summaries for easier inspection and recovery. (`agent/`)
|
||||
- **CLI input resume for agent runs** — the CLI can resume agent runs that require additional user input. (`cmd/micro/`, `agent/`)
|
||||
|
||||
### Changed
|
||||
- **Remote agent chat streaming** — `micro chat` now streams replies from remote agents instead of waiting for the full response. (`cmd/micro/`, `agent/`)
|
||||
|
||||
### Fixed
|
||||
- **Provider failure inspection metadata** — provider failures recorded during agent runs now retain classification metadata for inspection. (`agent/`, `ai/`)
|
||||
|
||||
---
|
||||
|
||||
## [6.7.0] - July 2026
|
||||
|
||||
### Added
|
||||
- **A2A streaming conformance harness** — A2A streaming behavior is now covered by focused conformance checks. (`gateway/a2a/`, `internal/harness/`)
|
||||
- **Agent x402 spend budget guardrail** — agents now have spend budget guardrails for x402-paid tool calls. (`agent/`, `gateway/`)
|
||||
- **First-agent chat/inspect fixture** — the maintained first-agent CLI fixture now covers chat and inspect boundaries together. (`internal/harness/`, `cmd/micro/`)
|
||||
- **Zero-to-hero inspect transcript check** — the 0→hero harness now verifies the inspect transcript path stays visible in the lifecycle walkthrough. (`internal/harness/zero-to-hero-ci/`, `internal/website/docs/`)
|
||||
|
||||
### Changed
|
||||
- **Agent stream run context propagation** — agent streams now preserve run context through streaming paths for more complete tracing and inspection. (`agent/`)
|
||||
- **Postgres store pgx v5 migration** — the Postgres store now uses pgx v5. (`store/postgres/`, `go.mod`)
|
||||
- **Plan-delegate plan persistence** — plan/delegate runs now persist plan state more defensively across harness scenarios. (`agent/`, `internal/harness/`)
|
||||
|
||||
### Fixed
|
||||
- **Nested tool-call markup rejection** — agent argument parsing now rejects nested tool-call markup instead of accepting ambiguous tool input. (`agent/`)
|
||||
- **Retry cancellation during backoff** — retry backoff now respects cancellation more reliably. (`agent/`, `ai/`)
|
||||
- **Plan-delegate mock recovery regression gate** — the harness now catches plan/delegate mock recovery regressions before they ship. (`internal/harness/`, `agent/`)
|
||||
- **First-agent fixture registration wait** — first-agent fixture registration is less race-prone during harness runs. (`internal/harness/`)
|
||||
- **Memory stream Nack ordering** — memory stream Nack handling now preserves ordering more reliably. (`broker/memory/`)
|
||||
- **Zero-to-hero fixture output race** — 0→hero fixture output is less race-prone during harness runs. (`internal/harness/zero-to-hero-ci/`)
|
||||
|
||||
### Documentation
|
||||
- **First-agent quickcheck wayfinding** — public docs now keep the quickcheck path discoverable from the first-agent route. (`README.md`, `internal/website/docs/`)
|
||||
- **Ordered 0→hero transcript** — docs and harness checks now keep the 0→hero transcript order explicit. (`internal/website/docs/`, `internal/harness/`)
|
||||
- **First-agent debug breadcrumbs** — docs now surface the first-agent debug smoke path more clearly. (`internal/website/docs/`)
|
||||
- **README badge cleanup** — the README no longer shows the Go Report Card badge. (`README.md`)
|
||||
|
||||
---
|
||||
|
||||
## [6.6.0] - July 2026
|
||||
|
||||
+41
-30
@@ -32,44 +32,55 @@ default.
|
||||
and history, end to end.
|
||||
5. Battle-tested: works across every provider, fails safely, observable.
|
||||
|
||||
## Now — hardening
|
||||
The forward work is **net-new capability**, not more hardening. Maintenance
|
||||
(conformance, resilience, DX polish) continues in the background (see *Ongoing*
|
||||
below) — but it is not the roadmap. This capability work is.
|
||||
|
||||
- **Cross-provider conformance** — the same agent scenario across all seven
|
||||
providers, gated on keys, on a schedule.
|
||||
- **Failure & resilience** — timeouts, rate limits, cancellation, deadline/context
|
||||
propagation, retry/backoff.
|
||||
- **Getting-started contract** — define and CI-verify the 0→1 and 0→hero flows.
|
||||
## Now — capability
|
||||
|
||||
## Shipped agent depth
|
||||
- **Agents that pay (x402 buyer in the runtime).** The seller side ships (paid
|
||||
tools via the `wrapper/x402` middleware) and the buyer `x402.Client` (a
|
||||
budget-capped `Payer` that turns a `402` into pay-and-retry) exists — but an
|
||||
agent can't yet *autonomously* pay for a paid tool. Wire the buyer into the
|
||||
agent tool loop: a budget-capped `AgentPayer` so an agent that hits a
|
||||
payment-required tool settles it within budget and retries, with the spend
|
||||
gated (like `ApproveTool`) and observable in `RunInfo`/traces. This makes
|
||||
go-micro a runtime for **autonomous agent commerce**. *(flagship — decomposed
|
||||
into issues in the loop queue)*
|
||||
- **AP2 mandate foundation** ([#3552](https://github.com/micro/go-micro/issues/3552))
|
||||
— verifiable payment **mandates** (a Checkout Mandate and a Payment Mandate),
|
||||
signed and attached over A2A, with the Payment Mandate naming an x402 rail. The
|
||||
authorization/audit layer above A2A + x402 that positions go-micro early in the
|
||||
emerging agent-payments standard (Google's AP2, standardized via FIDO).
|
||||
Additive and opt-in.
|
||||
|
||||
- **Durable agent loop** — opt-in `Checkpoint` support lets agent `Ask` and
|
||||
streaming runs persist, list pending work, and resume without replaying completed
|
||||
tool calls. Human-input pauses resume through explicit input helpers.
|
||||
- **Agent observability** — agent `RunInfo` now feeds OpenTelemetry spans/events
|
||||
across runs, model turns, tool calls, retries, delegation lineage, and resume
|
||||
checkpoints.
|
||||
## Next — reach & deployment
|
||||
|
||||
## Next — agentic depth
|
||||
- **gRPC-reflection MCP** — derive MCP tools from *any* gRPC service via server
|
||||
reflection, not just go-micro-native handlers. Point the gateway at an external
|
||||
gRPC service and its methods become agent tools — a large jump in what an agent
|
||||
can operate.
|
||||
- **Kubernetes operator + CRDs** — `Agent`, `Service`, and `Flow` as first-class
|
||||
Kubernetes resources; an operator reconciles them into Deployments wired to the
|
||||
registry. The production deployment story for teams already on K8s.
|
||||
|
||||
- **Streaming** — broaden provider-backed `ai.Stream` coverage and keep chat/A2A streaming end to end.
|
||||
- **Resume operations polish** — keep improving CLI/docs breadcrumbs for finding
|
||||
pending agent runs and deciding whether to call resume, resume-input, or stream
|
||||
resume in production.
|
||||
- **Observability hardening** — keep span attributes and run inspection coherent
|
||||
across agents, flows, and gateways as more providers and workflow paths are
|
||||
exercised.
|
||||
## Later — exploratory
|
||||
|
||||
## Later
|
||||
- **Runtime-fitness loop** — a persistently-running dogfood app (Mu) plus an
|
||||
operator/canary loop role, so the autonomous loop evolves go-micro against
|
||||
**real runtime signal** (latency, errors, cost) with canary + rollback — not
|
||||
just green CI. The demand signal the loop is missing today.
|
||||
- **HTTP/3 transport**; richer A2A live-stream reconnection (`tasks/resubscribe`,
|
||||
`input-required` handoffs); memory management (summarization, retrieval/RAG).
|
||||
|
||||
- Memory management (summarization, retrieval/RAG); human-in-the-loop pause/resume;
|
||||
richer A2A live-stream reconnection (`tasks/resubscribe`) and `input-required`
|
||||
handoffs.
|
||||
## Ongoing — hardening & DX (background, not the headline)
|
||||
|
||||
## Developer experience (ongoing)
|
||||
|
||||
- A seamless CLI inner loop (scaffold → run → chat → inspect → deploy); UI
|
||||
discipline (trim what isn't great); a maintained real-world example that doubles
|
||||
as the 0→hero reference; docs kept in lockstep with the code.
|
||||
Continuous but **capped** so it never crowds out capability: cross-provider
|
||||
conformance, failure/resilience (timeouts, cancellation, retry/backoff), the
|
||||
0→1 and 0→hero getting-started contract, streaming/observability coherence, and a
|
||||
seamless CLI inner loop (scaffold → run → chat → inspect → deploy). Real, but
|
||||
maintenance — the loop should spend the majority of its cycles on the capability above,
|
||||
not here.
|
||||
|
||||
## How it's sustained
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -263,6 +265,36 @@ func (a *agentImpl) Stream(ctx context.Context, message string) (ai.Stream, erro
|
||||
return &memoryRecordingStream{stream: stream, memory: a.mem}, nil
|
||||
}
|
||||
|
||||
// StreamChat serves the Agent.StreamChat RPC endpoint by forwarding stream-capable
|
||||
// remote clients to the agent streaming path. If the model cannot stream, the
|
||||
// underlying error is returned so callers can fall back to Agent.Chat.
|
||||
func (a *agentImpl) StreamChat(ctx context.Context, stream pb.Agent_StreamChatStream) error {
|
||||
req, err := stream.Recv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
aiStream, err := a.streamAskAI(ctx, req.Message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer aiStream.Close()
|
||||
for {
|
||||
chunk, err := aiStream.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if chunk == nil || chunk.Reply == "" {
|
||||
continue
|
||||
}
|
||||
if err := stream.Send(&pb.ChatResponse{Reply: chunk.Reply, Agent: a.opts.Name}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pending returns checkpointed agent runs that have not completed. It mirrors
|
||||
// flow.Pending for startup recovery loops that drain durable agent work.
|
||||
func Pending(ctx context.Context, ag Agent) ([]flow.Run, error) {
|
||||
@@ -368,6 +400,7 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
|
||||
Timeout: a.opts.ModelTimeout,
|
||||
MaxAttempts: a.opts.ModelMaxAttempts,
|
||||
Backoff: a.opts.ModelRetryBackoff,
|
||||
Jitter: a.opts.ModelRetryJitter,
|
||||
})
|
||||
if err != nil {
|
||||
run.Status = agentRunFailureStatus(err)
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
codecBytes "go-micro.dev/v6/codec/bytes"
|
||||
"go-micro.dev/v6/gateway/a2a"
|
||||
"go-micro.dev/v6/store"
|
||||
"go-micro.dev/v6/wrapper/x402"
|
||||
)
|
||||
|
||||
// Built-in agent tools. These are not service endpoints — they are
|
||||
@@ -128,6 +131,7 @@ func (a *agentImpl) toolHandler() ai.ToolHandler {
|
||||
// so the result runs plan → step → loop → approve → checkpoint → base.
|
||||
h := a.baseHandler()
|
||||
h = a.toolTimeoutWrap(h)
|
||||
h = a.x402PayWrap(h)
|
||||
h = a.toolRetryWrap(h)
|
||||
h = a.checkpointToolWrap(h)
|
||||
h = a.approveWrap(h)
|
||||
@@ -173,6 +177,64 @@ func (a *agentImpl) toolTimeoutWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// x402PayWrap pays an x402 Payment Required tool result and retries the
|
||||
// underlying HTTP tool once. Tools that proxy HTTP paid resources can return the
|
||||
// raw x402 402 challenge body and include a "url" input; the agent then uses
|
||||
// wrapper/x402.Client so payer and budget semantics stay in one place.
|
||||
func (a *agentImpl) x402PayWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
res := next(ctx, call)
|
||||
if res.Refused != "" || !isX402Challenge(res.Content) {
|
||||
return res
|
||||
}
|
||||
url, _ := call.Input["url"].(string)
|
||||
if url == "" {
|
||||
return errResult(call.ID, "x402: payment required but tool result did not include a retryable url input")
|
||||
}
|
||||
budget := a.opts.Budget
|
||||
if budget > 0 {
|
||||
remaining := budget - a.spend
|
||||
if remaining <= 0 {
|
||||
return refused(call.ID, ai.RefusedSpendBudget, fmt.Sprintf(
|
||||
"x402 spend budget exceeded: no budget remaining for %s (spent %d of %d)",
|
||||
call.Name, a.spend, budget))
|
||||
}
|
||||
budget = remaining
|
||||
}
|
||||
client := &x402.Client{Payer: a.opts.Payer, Budget: budget}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return errResult(call.ID, err.Error())
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "would exceed budget") {
|
||||
return refused(call.ID, ai.RefusedSpendBudget, err.Error())
|
||||
}
|
||||
return errResult(call.ID, err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return errResult(call.ID, err.Error())
|
||||
}
|
||||
a.spend += client.Spent()
|
||||
var value any
|
||||
if err := json.Unmarshal(body, &value); err != nil {
|
||||
value = string(body)
|
||||
}
|
||||
return ai.ToolResult{ID: call.ID, Value: value, Content: string(body), Attempts: 2}
|
||||
}
|
||||
}
|
||||
|
||||
func isX402Challenge(content string) bool {
|
||||
var ch struct {
|
||||
X402Version int `json:"x402Version"`
|
||||
Accepts []x402.Requirements `json:"accepts"`
|
||||
}
|
||||
return json.Unmarshal([]byte(content), &ch) == nil && ch.X402Version > 0 && len(ch.Accepts) > 0
|
||||
}
|
||||
|
||||
// toolRetryWrap retries transient tool failures with bounded backoff. It is
|
||||
// opt-in because tools can have side effects; guardrail refusals and caller
|
||||
// cancellation are never retried.
|
||||
@@ -389,6 +451,11 @@ func (a *agentImpl) spendWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
amount, call.Name, a.spend, a.opts.MaxSpend))
|
||||
}
|
||||
a.spend += amount
|
||||
if info, ok := ai.RunInfoFrom(ctx); ok {
|
||||
info.Spent = a.spend
|
||||
info.ToolSpend = amount
|
||||
ctx = ai.WithRunInfo(ctx, info)
|
||||
}
|
||||
res := next(ctx, call)
|
||||
if res.Refused != "" || toolErrorMessage(res) != "" {
|
||||
a.spend -= amount
|
||||
|
||||
@@ -2,12 +2,17 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/store"
|
||||
"go-micro.dev/v6/wrapper/x402"
|
||||
)
|
||||
|
||||
// toolContent runs a tool call through a handler and returns the content
|
||||
@@ -181,3 +186,132 @@ func TestNestedTextToolCallArgumentsAreRefused(t *testing.T) {
|
||||
t.Fatalf("content = %q, want nested tool-call refusal", content)
|
||||
}
|
||||
}
|
||||
|
||||
type agentMockPayer struct{ calls int }
|
||||
|
||||
func (p *agentMockPayer) Pay(ctx context.Context, req x402.Requirements) (string, error) {
|
||||
p.calls++
|
||||
return "paid", nil
|
||||
}
|
||||
|
||||
func TestAgentPayerPaysX402ToolResultAndRetries(t *testing.T) {
|
||||
paid := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get(x402.PaymentHeader) == "paid" {
|
||||
paid = true
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusPaymentRequired)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"x402Version": x402.Version,
|
||||
"accepts": []x402.Requirements{{Scheme: "exact", Network: "base", MaxAmountRequired: "7", Resource: r.URL.String(), PayTo: "0xmerchant"}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
payer := &agentMockPayer{}
|
||||
st := store.NewMemoryStore()
|
||||
a := newTestAgent(Name("x402-payer"), WithStore(st), Payer(payer), Budget(10), WithTool("paid.http", "paid http", nil, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(body), nil
|
||||
}))
|
||||
|
||||
ctx := ai.WithRunInfo(context.Background(), ai.RunInfo{RunID: "run-paid", Agent: "x402-payer"})
|
||||
res := a.toolHandler()(ctx, ai.ToolCall{ID: "pay-1", Name: "paid.http", Input: map[string]any{"url": srv.URL}})
|
||||
if !paid || payer.calls != 1 {
|
||||
t.Fatalf("payment not made: paid=%v payer.calls=%d", paid, payer.calls)
|
||||
}
|
||||
if res.Content != `{"ok":true}` || res.Attempts != 2 {
|
||||
t.Fatalf("result = %+v, want paid response with retry attempt", res)
|
||||
}
|
||||
events, err := LoadRunEvents(st, "x402-payer", "run-paid")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 1 || events[0].Spent != 7 || events[0].ToolSpend != 7 {
|
||||
t.Fatalf("spend events = %#v, want one tool event with spent/tool_spend 7", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentPayerRefusesX402OverBudget(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusPaymentRequired)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"x402Version": x402.Version,
|
||||
"accepts": []x402.Requirements{{Scheme: "exact", Network: "base", MaxAmountRequired: "70", Resource: r.URL.String(), PayTo: "0xmerchant"}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
payer := &agentMockPayer{}
|
||||
a := newTestAgent(Name("x402-over-budget"), Payer(payer), Budget(10), WithTool("paid.http", "paid http", nil, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(body), nil
|
||||
}))
|
||||
|
||||
res := a.toolHandler()(context.Background(), ai.ToolCall{ID: "pay-1", Name: "paid.http", Input: map[string]any{"url": srv.URL}})
|
||||
if payer.calls != 0 {
|
||||
t.Fatalf("payer called despite over-budget refusal")
|
||||
}
|
||||
if res.Refused != ai.RefusedSpendBudget || !strings.Contains(res.Content, "would exceed budget") {
|
||||
t.Fatalf("result = %+v, want budget refusal", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentPayerRequiredWithoutPayerReturnsClearError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusPaymentRequired)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"x402Version": x402.Version,
|
||||
"accepts": []x402.Requirements{{Scheme: "exact", Network: "base", MaxAmountRequired: "7", Resource: r.URL.String(), PayTo: "0xmerchant"}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
a := newTestAgent(Name("x402-no-payer"), Budget(10), WithTool("paid.http", "paid http", nil, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(body), nil
|
||||
}))
|
||||
|
||||
res := a.toolHandler()(context.Background(), ai.ToolCall{ID: "pay-1", Name: "paid.http", Input: map[string]any{"url": srv.URL}})
|
||||
if !strings.Contains(res.Content, "no Payer configured") {
|
||||
t.Fatalf("content = %q, want no payer error", res.Content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,4 +248,12 @@ func TestCompactingMemorySummarizesAndRecallsArchivedContext(t *testing.T) {
|
||||
if !sawRecall {
|
||||
t.Error("model request did not recall archived matching context")
|
||||
}
|
||||
summary := Summary(a.mem)
|
||||
if !strings.Contains(summary, "Conversation memory summary") || !strings.Contains(summary, "alpha") {
|
||||
t.Fatalf("inspectable memory summary = %q, want compacted alpha summary", summary)
|
||||
}
|
||||
a.mem.Clear()
|
||||
if summary := Summary(a.mem); summary != "" {
|
||||
t.Fatalf("summary after Clear = %q, want empty", summary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,27 @@ type MemoryRecall interface {
|
||||
Recall(query string, limit int) []ai.Message
|
||||
}
|
||||
|
||||
// MemorySummary is implemented by memory backends that expose their current
|
||||
// compacted summary for inspection. It lets long-running agents make memory
|
||||
// compaction observable without coupling callers to a concrete store.
|
||||
type MemorySummary interface {
|
||||
Summary() string
|
||||
}
|
||||
|
||||
// Summary returns the current compacted-memory summary for m, when supported.
|
||||
// It returns an empty string for memory backends that have not compacted or do
|
||||
// not expose an inspectable summary.
|
||||
func Summary(m Memory) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
summarizer, ok := m.(MemorySummary)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return summarizer.Summary()
|
||||
}
|
||||
|
||||
// NewMemory returns the default store-backed memory: an in-process
|
||||
// conversation buffer (truncated to limit) that persists to the store
|
||||
// under key, so an agent picks up where it left off after a restart.
|
||||
@@ -116,6 +137,7 @@ type storeMemory struct {
|
||||
hist *ai.History
|
||||
compaction MemoryCompaction
|
||||
archive []ai.Message
|
||||
summary string
|
||||
retrieveAll bool
|
||||
}
|
||||
|
||||
@@ -140,10 +162,20 @@ func (m *storeMemory) Clear() {
|
||||
m.mu.Lock()
|
||||
m.hist.Reset()
|
||||
m.archive = nil
|
||||
m.summary = ""
|
||||
m.mu.Unlock()
|
||||
m.save()
|
||||
}
|
||||
|
||||
// Summary returns the latest compacted summary text, if this memory has
|
||||
// compacted older turns. The returned value is safe to show in debug UIs or
|
||||
// checkpoints because it is exactly the summary retained in active context.
|
||||
func (m *storeMemory) Summary() string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.summary
|
||||
}
|
||||
|
||||
// Recall returns archived messages whose content contains words from query.
|
||||
// It is deterministic and provider-neutral: no embeddings or model calls are
|
||||
// required, but semantic/vector stores can replace Memory for richer retrieval.
|
||||
@@ -202,12 +234,16 @@ func (m *storeMemory) load() {
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.archive = state.Archive
|
||||
m.summary = state.Summary
|
||||
if m.retrieveAll && len(m.archive) == 0 {
|
||||
m.archive = append(m.archive, state.Messages...)
|
||||
}
|
||||
for _, msg := range state.Messages {
|
||||
m.hist.Add(msg.Role, msg.Content)
|
||||
}
|
||||
if m.summary == "" {
|
||||
m.summary = currentMemorySummary(state.Messages)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -219,6 +255,7 @@ func (m *storeMemory) save() {
|
||||
data, err := json.Marshal(memoryState{
|
||||
Messages: m.hist.Messages(),
|
||||
Archive: m.archive,
|
||||
Summary: m.summary,
|
||||
})
|
||||
m.mu.Unlock()
|
||||
if err != nil {
|
||||
@@ -256,6 +293,7 @@ func (m *storeMemory) compact() {
|
||||
if summary.Role == "" {
|
||||
summary.Role = "system"
|
||||
}
|
||||
m.summary = fmt.Sprint(summary.Content)
|
||||
m.hist.Reset()
|
||||
m.hist.Add(summary.Role, summary.Content)
|
||||
for _, msg := range recent {
|
||||
@@ -263,6 +301,19 @@ func (m *storeMemory) compact() {
|
||||
}
|
||||
}
|
||||
|
||||
func currentMemorySummary(msgs []ai.Message) string {
|
||||
for _, msg := range msgs {
|
||||
if msg.Role != "system" {
|
||||
continue
|
||||
}
|
||||
text := fmt.Sprint(msg.Content)
|
||||
if strings.HasPrefix(text, "Conversation memory summary:") {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func defaultMemorySummary(msgs []ai.Message) ai.Message {
|
||||
return ai.Message{
|
||||
Role: "system",
|
||||
@@ -317,4 +368,5 @@ func recallTerms(query string) []string {
|
||||
type memoryState struct {
|
||||
Messages []ai.Message `json:"messages"`
|
||||
Archive []ai.Message `json:"archive,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
@@ -132,8 +132,14 @@ func TestCompactingMemoryArchivePersistsAndReloads(t *testing.T) {
|
||||
m.Add("assistant", "noted")
|
||||
m.Add("user", "beta budget is 7")
|
||||
m.Add("assistant", "noted")
|
||||
if summary := Summary(m); !strings.Contains(summary, "alpha budget is 42") {
|
||||
t.Fatalf("inspectable summary = %q, want alpha budget", summary)
|
||||
}
|
||||
|
||||
reloaded := NewCompactingMemory(st, "agent/reload/history", 3, 1)
|
||||
if summary := Summary(reloaded); !strings.Contains(summary, "alpha budget is 42") {
|
||||
t.Fatalf("reloaded summary = %q, want alpha budget", summary)
|
||||
}
|
||||
recall, ok := reloaded.(MemoryRecall)
|
||||
if !ok {
|
||||
t.Fatal("compacting memory should support recall")
|
||||
@@ -165,8 +171,14 @@ func TestCompactingMemoryUsesCustomSummarizerAndReloadsRecall(t *testing.T) {
|
||||
if len(msgs) == 0 || msgs[0].Content != "custom summary count=3" {
|
||||
t.Fatalf("summary = %#v, want custom summarizer output", msgs)
|
||||
}
|
||||
if summary := Summary(m); summary != "custom summary count=3" {
|
||||
t.Fatalf("inspectable custom summary = %q, want custom summary count=3", summary)
|
||||
}
|
||||
|
||||
reloaded := NewCompactingMemoryWithOptions(st, "agent/custom/history", MemoryCompaction{MaxMessages: 3, KeepRecent: 1})
|
||||
if summary := Summary(reloaded); summary != "custom summary count=3" {
|
||||
t.Fatalf("reloaded custom summary = %q, want custom summary count=3", summary)
|
||||
}
|
||||
recall := reloaded.(MemoryRecall)
|
||||
recalled := recall.Recall("alpha budget", 1)
|
||||
if len(recalled) != 1 {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/store"
|
||||
"go-micro.dev/v6/wrapper/x402"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
@@ -59,6 +60,9 @@ type Options struct {
|
||||
// ModelRetryBackoff is the base delay between transient provider failures
|
||||
// (grows exponentially per attempt when retries are enabled).
|
||||
ModelRetryBackoff time.Duration
|
||||
// ModelRetryJitter adds up to this random delay to each provider retry
|
||||
// backoff. Default 0 preserves deterministic timing unless explicitly set.
|
||||
ModelRetryJitter time.Duration
|
||||
// ToolTimeout bounds each tool execution (0 disables). The timeout is
|
||||
// applied before custom tools, delegate, and service RPC calls so context
|
||||
// deadlines propagate consistently through the agent loop.
|
||||
@@ -102,6 +106,10 @@ type Options struct {
|
||||
// unit (0 = disabled). ToolSpend lists known paid tools and their prices.
|
||||
MaxSpend int64
|
||||
ToolSpend map[string]int64
|
||||
// Payer lets the agent settle x402 Payment Required challenges from tools.
|
||||
// Budget bounds autonomous x402 payments per Ask (0 = unlimited).
|
||||
Payer x402.Payer
|
||||
Budget int64
|
||||
|
||||
// A2AAddress, if set, makes Run serve this agent over the A2A protocol
|
||||
// on that address directly (no separate gateway), e.g. ":4000".
|
||||
@@ -247,6 +255,18 @@ func ToolSpend(tool string, amount int64) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// Payer configures the wallet/signing hook used to settle x402-paid tools.
|
||||
// Without a payer, payment-required tool results are returned as clear errors.
|
||||
func Payer(p x402.Payer) Option {
|
||||
return func(o *Options) { o.Payer = p }
|
||||
}
|
||||
|
||||
// Budget bounds autonomous x402 payments per Ask, in the asset's smallest
|
||||
// unit (0 = unlimited). The budget is enforced by wrapper/x402.Client.
|
||||
func Budget(amount int64) Option {
|
||||
return func(o *Options) { o.Budget = amount }
|
||||
}
|
||||
|
||||
// LoopLimit sets how many times the agent may repeat the same tool call
|
||||
// (same name and arguments) in one Ask before it is refused as a
|
||||
// no-progress loop. 0 disables loop detection.
|
||||
@@ -275,6 +295,12 @@ func ModelRetry(maxAttempts int, backoff time.Duration) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// ModelRetryJitter adds bounded random jitter to provider retry backoff.
|
||||
// Set 0 to disable.
|
||||
func ModelRetryJitter(d time.Duration) Option {
|
||||
return func(o *Options) { o.ModelRetryJitter = d }
|
||||
}
|
||||
|
||||
// ToolRetry sets the tool retry budget and backoff for transient failures.
|
||||
// Attempts include the first call. Retries are opt-in because tools may have
|
||||
// side effects; keep handlers idempotent before enabling this.
|
||||
|
||||
+37
-3
@@ -51,6 +51,8 @@ const (
|
||||
AttrDispatch = "agent.dispatch"
|
||||
AttrTrigger = "agent.trigger"
|
||||
AttrRunEventKind = "agent.event.kind"
|
||||
AttrSpend = "agent.spend"
|
||||
AttrToolSpend = "agent.tool.spend"
|
||||
)
|
||||
|
||||
type RunEvent struct {
|
||||
@@ -73,6 +75,8 @@ type RunEvent struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorKind string `json:"error_kind,omitempty"`
|
||||
InputChars int `json:"input_chars,omitempty"`
|
||||
Spent int64 `json:"spent,omitempty"`
|
||||
ToolSpend int64 `json:"tool_spend,omitempty"`
|
||||
}
|
||||
|
||||
type Usage = ai.Usage
|
||||
@@ -82,7 +86,8 @@ type Usage = ai.Usage
|
||||
type RunListOptions struct {
|
||||
// Status, when set, keeps only runs with the matching status
|
||||
// (for example "running", "done", "canceled", "timeout",
|
||||
// "rate_limited", "error", or "refused").
|
||||
// "rate_limited", "auth", "configuration", "unavailable",
|
||||
// "provider_error", "error", or "refused").
|
||||
Status string
|
||||
// TraceID, when set, keeps only runs correlated with this trace id.
|
||||
// A prefix is accepted so operators can paste the shortened trace id
|
||||
@@ -110,6 +115,7 @@ type RunSummary struct {
|
||||
LastKind string `json:"last_kind,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastErrorKind string `json:"last_error_kind,omitempty"`
|
||||
Spent int64 `json:"spent,omitempty"`
|
||||
}
|
||||
|
||||
func (a *agentImpl) tracer() trace.Tracer {
|
||||
@@ -361,6 +367,7 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
|
||||
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
start := time.Now()
|
||||
spentBefore := a.spend
|
||||
|
||||
if a.opts.TraceProvider == nil {
|
||||
res := next(ctx, call)
|
||||
@@ -370,7 +377,7 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
|
||||
if toolAttempts <= 0 {
|
||||
toolAttempts = 1
|
||||
}
|
||||
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, Attempt: toolAttempts, MaxAttempts: a.opts.ToolMaxAttempts, LatencyMS: dur, Refused: res.Refused, Error: resErr, ErrorKind: classifyToolError(resErr)})
|
||||
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, Attempt: toolAttempts, MaxAttempts: a.opts.ToolMaxAttempts, LatencyMS: dur, Refused: res.Refused, Error: resErr, ErrorKind: classifyToolError(resErr), Spent: a.spend, ToolSpend: a.spend - spentBefore})
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -384,6 +391,7 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
|
||||
ctx, span := a.tracer().Start(ctx, spanNameToolCall, trace.WithAttributes(spanAttrs...))
|
||||
res := next(ctx, call)
|
||||
dur := time.Since(start).Milliseconds()
|
||||
toolSpend := a.spend - spentBefore
|
||||
attrs := []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
|
||||
toolAttempts := res.Attempts
|
||||
if toolAttempts <= 0 {
|
||||
@@ -393,6 +401,9 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
|
||||
if a.opts.ToolMaxAttempts > 0 {
|
||||
attrs = append(attrs, attribute.Int(AttrToolMaxAttempts, a.opts.ToolMaxAttempts))
|
||||
}
|
||||
if toolSpend > 0 {
|
||||
attrs = append(attrs, attribute.Int64(AttrSpend, a.spend), attribute.Int64(AttrToolSpend, toolSpend))
|
||||
}
|
||||
if res.Refused != "" {
|
||||
attrs = append(attrs, attribute.Bool(AttrGuardrailBlock, true), attribute.String(AttrRefusal, res.Refused))
|
||||
}
|
||||
@@ -408,7 +419,7 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
}
|
||||
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, Attempt: toolAttempts, MaxAttempts: a.opts.ToolMaxAttempts, LatencyMS: dur, Refused: res.Refused, Error: resErr, ErrorKind: classifyToolError(resErr)})
|
||||
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, Attempt: toolAttempts, MaxAttempts: a.opts.ToolMaxAttempts, LatencyMS: dur, Refused: res.Refused, Error: resErr, ErrorKind: classifyToolError(resErr), Spent: a.spend, ToolSpend: toolSpend})
|
||||
span.End()
|
||||
return res
|
||||
}
|
||||
@@ -495,6 +506,12 @@ func runEventAttributes(e RunEvent) []attribute.KeyValue {
|
||||
if e.InputChars > 0 {
|
||||
attrs = append(attrs, attribute.Int(AttrInputChars, e.InputChars))
|
||||
}
|
||||
if e.Spent > 0 {
|
||||
attrs = append(attrs, attribute.Int64(AttrSpend, e.Spent))
|
||||
}
|
||||
if e.ToolSpend > 0 {
|
||||
attrs = append(attrs, attribute.Int64(AttrToolSpend, e.ToolSpend))
|
||||
}
|
||||
attrs = appendUsage(attrs, e.Tokens)
|
||||
if e.Refused != "" {
|
||||
attrs = append(attrs, attribute.Bool(AttrGuardrailBlock, true), attribute.String(AttrRefusal, e.Refused))
|
||||
@@ -529,6 +546,12 @@ func appendRunInfoAttributes(attrs []attribute.KeyValue, info ai.RunInfo) []attr
|
||||
if info.Trigger != "" {
|
||||
attrs = append(attrs, attribute.String(AttrTrigger, info.Trigger))
|
||||
}
|
||||
if info.Spent > 0 {
|
||||
attrs = append(attrs, attribute.Int64(AttrSpend, info.Spent))
|
||||
}
|
||||
if info.ToolSpend > 0 {
|
||||
attrs = append(attrs, attribute.Int64(AttrToolSpend, info.ToolSpend))
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
@@ -615,6 +638,9 @@ func ListRunSummariesWithOptions(s store.Store, agentName string, opts RunListOp
|
||||
if e.ErrorKind != "" {
|
||||
summary.LastErrorKind = e.ErrorKind
|
||||
}
|
||||
if e.Spent > summary.Spent {
|
||||
summary.Spent = e.Spent
|
||||
}
|
||||
}
|
||||
if opts.Status != "" && summary.Status != opts.Status {
|
||||
continue
|
||||
@@ -662,6 +688,14 @@ func runErrorStatus(kind string) string {
|
||||
return "timeout"
|
||||
case ai.ErrorKindRateLimited:
|
||||
return "rate_limited"
|
||||
case ai.ErrorKindAuth:
|
||||
return "auth"
|
||||
case ai.ErrorKindConfiguration:
|
||||
return "configuration"
|
||||
case ai.ErrorKindUnavailable:
|
||||
return "unavailable"
|
||||
case ai.ErrorKindProvider:
|
||||
return "provider_error"
|
||||
default:
|
||||
return "error"
|
||||
}
|
||||
|
||||
+61
-1
@@ -421,6 +421,63 @@ func spanAttributes(attrs []attribute.KeyValue) map[string]string {
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAgentOpenTelemetryToolSpanIncludesSpend(t *testing.T) {
|
||||
exp := tracetest.NewInMemoryExporter()
|
||||
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
|
||||
st := store.NewMemoryStore()
|
||||
a := New(Name("spender"), Provider("oteltest"), Model("unit-model"), WithStore(st), TraceProvider(tp), MaxSpend(10), ToolSpend("probe", 7), WithTool("probe", "probe", nil, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
info, ok := ai.RunInfoFrom(ctx)
|
||||
if !ok {
|
||||
t.Fatal("RunInfo missing from paid tool context")
|
||||
}
|
||||
if info.Spent != 7 || info.ToolSpend != 7 {
|
||||
t.Fatalf("RunInfo spend = (%d, %d), want (7, 7)", info.Spent, info.ToolSpend)
|
||||
}
|
||||
return "ok", nil
|
||||
}))
|
||||
if _, err := a.Ask(context.Background(), "hello"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var sawToolSpan bool
|
||||
for _, s := range exp.GetSpans().Snapshots() {
|
||||
if s.Name() != spanNameToolCall {
|
||||
continue
|
||||
}
|
||||
sawToolSpan = true
|
||||
attrs := spanAttributes(s.Attributes())
|
||||
if attrs[AttrSpend] != "7" || attrs[AttrToolSpend] != "7" {
|
||||
t.Fatalf("tool span missing spend attributes: %#v", attrs)
|
||||
}
|
||||
if !spanEventHasAttribute(s.Events(), "agent.tool", AttrToolSpend, "7") {
|
||||
t.Fatalf("tool event missing spend attribute: %#v", s.Events())
|
||||
}
|
||||
}
|
||||
if !sawToolSpan {
|
||||
t.Fatal("tool span not emitted")
|
||||
}
|
||||
summaries, err := ListRunSummaries(st, "spender")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(summaries) != 1 || summaries[0].Spent != 7 {
|
||||
t.Fatalf("summary spend = %#v, want 7", summaries)
|
||||
}
|
||||
}
|
||||
|
||||
func spanEventHasAttribute(events []trace.Event, name, key, value string) bool {
|
||||
for _, e := range events {
|
||||
if e.Name != name {
|
||||
continue
|
||||
}
|
||||
attrs := spanAttributes(e.Attributes)
|
||||
if attrs[key] == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAgentOpenTelemetrySpansDelegateLineage(t *testing.T) {
|
||||
exp := tracetest.NewInMemoryExporter()
|
||||
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
|
||||
@@ -663,7 +720,10 @@ func TestRunStatusClassifiesOperationalErrorKinds(t *testing.T) {
|
||||
{name: "canceled", kind: ai.ErrorKindCanceled, want: "canceled"},
|
||||
{name: "timeout", kind: ai.ErrorKindTimeout, want: "timeout"},
|
||||
{name: "rate limited", kind: ai.ErrorKindRateLimited, want: "rate_limited"},
|
||||
{name: "provider", kind: ai.ErrorKindProvider, want: "error"},
|
||||
{name: "auth", kind: ai.ErrorKindAuth, want: "auth"},
|
||||
{name: "configuration", kind: ai.ErrorKindConfiguration, want: "configuration"},
|
||||
{name: "unavailable", kind: ai.ErrorKindUnavailable, want: "unavailable"},
|
||||
{name: "provider", kind: ai.ErrorKindProvider, want: "provider_error"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -29,6 +29,7 @@ var _ server.Option
|
||||
|
||||
type AgentService interface {
|
||||
Chat(ctx context.Context, in *ChatRequest, opts ...client.CallOption) (*ChatResponse, error)
|
||||
StreamChat(ctx context.Context, in *ChatRequest, opts ...client.CallOption) (Agent_StreamChatService, error)
|
||||
}
|
||||
|
||||
type agentService struct {
|
||||
@@ -53,6 +54,40 @@ func (c *agentService) Chat(ctx context.Context, in *ChatRequest, opts ...client
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *agentService) StreamChat(ctx context.Context, in *ChatRequest, opts ...client.CallOption) (Agent_StreamChatService, error) {
|
||||
req := c.c.NewRequest(c.name, "Agent.StreamChat", in)
|
||||
stream, err := c.c.Stream(ctx, req, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := stream.Send(in); err != nil {
|
||||
_ = stream.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &agentServiceStreamChat{stream}, nil
|
||||
}
|
||||
|
||||
type Agent_StreamChatService interface {
|
||||
Close() error
|
||||
Recv() (*ChatResponse, error)
|
||||
}
|
||||
|
||||
type agentServiceStreamChat struct {
|
||||
stream client.Stream
|
||||
}
|
||||
|
||||
func (x *agentServiceStreamChat) Close() error {
|
||||
return x.stream.Close()
|
||||
}
|
||||
|
||||
func (x *agentServiceStreamChat) Recv() (*ChatResponse, error) {
|
||||
m := new(ChatResponse)
|
||||
if err := x.stream.Recv(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Server API for Agent service
|
||||
|
||||
type AgentHandler interface {
|
||||
@@ -62,6 +97,7 @@ type AgentHandler interface {
|
||||
func RegisterAgentHandler(s server.Server, hdlr AgentHandler, opts ...server.HandlerOption) error {
|
||||
type agent interface {
|
||||
Chat(ctx context.Context, in *ChatRequest, out *ChatResponse) error
|
||||
StreamChat(ctx context.Context, stream server.Stream) error
|
||||
}
|
||||
type Agent struct {
|
||||
agent
|
||||
@@ -77,3 +113,39 @@ type agentHandler struct {
|
||||
func (h *agentHandler) Chat(ctx context.Context, in *ChatRequest, out *ChatResponse) error {
|
||||
return h.AgentHandler.Chat(ctx, in, out)
|
||||
}
|
||||
|
||||
func (h *agentHandler) StreamChat(ctx context.Context, stream server.Stream) error {
|
||||
streamer, ok := h.AgentHandler.(interface {
|
||||
StreamChat(context.Context, Agent_StreamChatStream) error
|
||||
})
|
||||
if !ok {
|
||||
return fmt.Errorf("agent: StreamChat unsupported")
|
||||
}
|
||||
return streamer.StreamChat(ctx, &agentStreamChatStream{stream})
|
||||
}
|
||||
|
||||
type Agent_StreamChatStream interface {
|
||||
Close() error
|
||||
Send(*ChatResponse) error
|
||||
Recv() (*ChatRequest, error)
|
||||
}
|
||||
|
||||
type agentStreamChatStream struct {
|
||||
stream server.Stream
|
||||
}
|
||||
|
||||
func (x *agentStreamChatStream) Close() error {
|
||||
return x.stream.Close()
|
||||
}
|
||||
|
||||
func (x *agentStreamChatStream) Send(m *ChatResponse) error {
|
||||
return x.stream.Send(m)
|
||||
}
|
||||
|
||||
func (x *agentStreamChatStream) Recv() (*ChatRequest, error) {
|
||||
m := new(ChatRequest)
|
||||
if err := x.stream.Recv(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ option go_package = "./proto;agent";
|
||||
// Agent is the RPC interface for an AI agent.
|
||||
service Agent {
|
||||
rpc Chat(ChatRequest) returns (ChatResponse) {}
|
||||
rpc StreamChat(ChatRequest) returns (stream ChatResponse) {}
|
||||
}
|
||||
|
||||
message ChatRequest {
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestRegisteredProviders(t *testing.T) {
|
||||
}
|
||||
|
||||
got = ai.RegisteredProviders("stream")
|
||||
want = []string{"anthropic", "atlascloud", "groq", "minimax", "mistral", "openai", "together"}
|
||||
want = []string{"anthropic", "atlascloud", "gemini", "groq", "minimax", "mistral", "openai", "together"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func TestCapabilityRows(t *testing.T) {
|
||||
want := []ai.CapabilityRow{
|
||||
{Provider: "anthropic", Capabilities: ai.Capabilities{Model: true, Stream: true, ToolStream: true}},
|
||||
{Provider: "atlascloud", Capabilities: ai.Capabilities{Model: true, Image: true, Video: true, Stream: true}},
|
||||
{Provider: "gemini", Capabilities: ai.Capabilities{Model: true}},
|
||||
{Provider: "gemini", Capabilities: ai.Capabilities{Model: true, Stream: true}},
|
||||
{Provider: "groq", Capabilities: ai.Capabilities{Model: true, Stream: true, ToolStream: true}},
|
||||
{Provider: "minimax", Capabilities: ai.Capabilities{Model: true, Stream: true, ToolStream: true}},
|
||||
{Provider: "mistral", Capabilities: ai.Capabilities{Model: true, Stream: true, ToolStream: true}},
|
||||
@@ -90,7 +90,7 @@ func TestRegisterStream(t *testing.T) {
|
||||
}
|
||||
|
||||
got := ai.RegisteredProviders("stream")
|
||||
want := []string{"anthropic", "atlascloud", "groq", "minimax", "mistral", "openai", "test-stream", "together"}
|
||||
want := []string{"anthropic", "atlascloud", "gemini", "groq", "minimax", "mistral", "openai", "test-stream", "together"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
+136
-4
@@ -10,6 +10,7 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -25,6 +26,7 @@ func init() {
|
||||
ai.Register("gemini", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
ai.RegisterStream("gemini")
|
||||
}
|
||||
|
||||
// Provider implements the ai.Model interface for Google Gemini.
|
||||
@@ -69,9 +71,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
})
|
||||
}
|
||||
|
||||
contents := []map[string]any{
|
||||
{"role": "user", "parts": []map[string]any{{"text": req.Prompt}}},
|
||||
}
|
||||
contents := geminiContents(req)
|
||||
|
||||
apiReq := map[string]any{
|
||||
"contents": contents,
|
||||
@@ -135,7 +135,121 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
}
|
||||
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("%w: gemini provider", ai.ErrStreamingUnsupported)
|
||||
apiReq := map[string]any{
|
||||
"contents": geminiContents(req),
|
||||
}
|
||||
if req.SystemPrompt != "" {
|
||||
apiReq["system_instruction"] = map[string]any{
|
||||
"parts": []map[string]any{{"text": req.SystemPrompt}},
|
||||
}
|
||||
}
|
||||
if p.opts.MaxTokens > 0 {
|
||||
apiReq["generationConfig"] = map[string]any{"maxOutputTokens": p.opts.MaxTokens}
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(apiReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") +
|
||||
"/v1beta/models/" + p.opts.Model + ":streamGenerateContent?alt=sse"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create stream request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Accept", "text/event-stream")
|
||||
httpReq.Header.Set("x-goog-api-key", p.opts.APIKey)
|
||||
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stream API request failed: %w", err)
|
||||
}
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
defer httpResp.Body.Close()
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
return nil, ai.NewHTTPError(httpResp, respBody)
|
||||
}
|
||||
return &streamReader{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
|
||||
}
|
||||
|
||||
type streamReader struct {
|
||||
body io.ReadCloser
|
||||
scanner *bufio.Scanner
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (s *streamReader) Recv() (*ai.Response, error) {
|
||||
for s.scanner.Scan() {
|
||||
line := strings.TrimSpace(s.scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, ":") || strings.HasPrefix(line, "event:") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if data == "[DONE]" {
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
var chunk struct {
|
||||
Error *struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
} `json:"error"`
|
||||
Candidates []struct {
|
||||
Content struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"content"`
|
||||
} `json:"candidates"`
|
||||
UsageMetadata *struct {
|
||||
PromptTokenCount int `json:"promptTokenCount"`
|
||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||
TotalTokenCount int `json:"totalTokenCount"`
|
||||
} `json:"usageMetadata"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
|
||||
}
|
||||
if chunk.Error != nil {
|
||||
return nil, fmt.Errorf("gemini stream error (%s): %s", chunk.Error.Status, chunk.Error.Message)
|
||||
}
|
||||
for _, candidate := range chunk.Candidates {
|
||||
var parts []string
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.Text != "" {
|
||||
parts = append(parts, part.Text)
|
||||
}
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
return &ai.Response{Reply: strings.Join(parts, "")}, nil
|
||||
}
|
||||
}
|
||||
if chunk.UsageMetadata != nil {
|
||||
return &ai.Response{Usage: ai.Usage{
|
||||
InputTokens: chunk.UsageMetadata.PromptTokenCount,
|
||||
OutputTokens: chunk.UsageMetadata.CandidatesTokenCount,
|
||||
TotalTokens: chunk.UsageMetadata.TotalTokenCount,
|
||||
}}, nil
|
||||
}
|
||||
}
|
||||
if err := s.scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
func (s *streamReader) Close() error {
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
return s.body.Close()
|
||||
}
|
||||
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, []map[string]any, error) {
|
||||
@@ -224,3 +338,21 @@ type functionCallPB struct {
|
||||
Name string `json:"name"`
|
||||
Args map[string]any `json:"args"`
|
||||
}
|
||||
|
||||
func geminiContents(req *ai.Request) []map[string]any {
|
||||
contents := make([]map[string]any, 0, len(req.Messages)+1)
|
||||
for _, m := range req.Messages {
|
||||
role := m.Role
|
||||
if role == "assistant" {
|
||||
role = "model"
|
||||
}
|
||||
if role == "system" || role == "" {
|
||||
continue
|
||||
}
|
||||
contents = append(contents, map[string]any{"role": role, "parts": []map[string]any{{"text": fmt.Sprint(m.Content)}}})
|
||||
}
|
||||
if req.Prompt != "" {
|
||||
contents = append(contents, map[string]any{"role": "user", "parts": []map[string]any{{"text": req.Prompt}}})
|
||||
}
|
||||
return contents
|
||||
}
|
||||
|
||||
+103
-6
@@ -2,7 +2,12 @@ package gemini
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -81,16 +86,108 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
p := NewProvider()
|
||||
func TestProvider_Stream(t *testing.T) {
|
||||
var sawRequest bool
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sawRequest = true
|
||||
if r.URL.Path != "/v1beta/models/gemini-2.5-flash:streamGenerateContent" {
|
||||
t.Fatalf("path = %s, want streamGenerateContent", r.URL.Path)
|
||||
}
|
||||
if r.URL.Query().Get("alt") != "sse" {
|
||||
t.Fatalf("alt = %q, want sse", r.URL.Query().Get("alt"))
|
||||
}
|
||||
if got := r.Header.Get("Accept"); got != "text/event-stream" {
|
||||
t.Fatalf("Accept = %q, want text/event-stream", got)
|
||||
}
|
||||
if got := r.Header.Get("x-goog-api-key"); got != "test-key" {
|
||||
t.Fatalf("x-goog-api-key = %q, want test-key", got)
|
||||
}
|
||||
|
||||
req := &ai.Request{
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
contents, ok := body["contents"].([]any)
|
||||
if !ok || len(contents) != 3 {
|
||||
t.Fatalf("contents = %#v, want history + prompt", body["contents"])
|
||||
}
|
||||
second := contents[1].(map[string]any)
|
||||
if second["role"] != "model" {
|
||||
t.Fatalf("assistant history role = %#v, want model", second["role"])
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"hel\"}]}}]}\n\n"))
|
||||
_, _ = w.Write([]byte("data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"lo\"}]}}],\"usageMetadata\":{\"promptTokenCount\":3,\"candidatesTokenCount\":2,\"totalTokenCount\":5}}\n\n"))
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
|
||||
stream, err := p.Stream(context.Background(), &ai.Request{
|
||||
Messages: []ai.Message{
|
||||
{Role: "user", Content: "previous question"},
|
||||
{Role: "assistant", Content: "previous answer"},
|
||||
},
|
||||
Prompt: "Hello",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream returned error: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
if !sawRequest {
|
||||
t.Fatal("server did not receive stream request")
|
||||
}
|
||||
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
first, err := stream.Recv()
|
||||
if err != nil || first.Reply != "hel" {
|
||||
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
|
||||
}
|
||||
second, err := stream.Recv()
|
||||
if err != nil || second.Reply != "lo" {
|
||||
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
|
||||
}
|
||||
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("final error = %v, want EOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_StreamPropagatesMalformedChunk(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {bad json}\n\n"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
|
||||
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream returned error: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
if _, err := stream.Recv(); err == nil {
|
||||
t.Fatal("Recv returned nil error for malformed chunk")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_StreamPropagatesProviderError(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "quota exhausted", http.StatusTooManyRequests)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
|
||||
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
|
||||
if err == nil {
|
||||
_ = stream.Close()
|
||||
t.Fatal("Stream returned nil error for provider failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "429") || !strings.Contains(err.Error(), "quota exhausted") {
|
||||
t.Fatalf("Stream error = %v, want provider status and body", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "test-key") {
|
||||
t.Fatal("stream error leaked API key")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,8 @@ type RunInfo struct {
|
||||
VerificationFeedback string // feedback from the previous failed verifier attempt, when retrying a flow step
|
||||
Dispatch string // how the run was dispatched (direct, broker, schedule, resume) when known
|
||||
Trigger string // external trigger or schedule label that started the run, when known
|
||||
Spent int64 // cumulative paid x402 spend in this run, in the asset's smallest unit
|
||||
ToolSpend int64 // paid x402 spend attributed to the current tool call, in the asset's smallest unit
|
||||
}
|
||||
|
||||
type runInfoKey struct{}
|
||||
|
||||
+28
-7
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -84,12 +85,14 @@ func parseRetryAfter(value string, now time.Time) time.Duration {
|
||||
type ErrorKind string
|
||||
|
||||
const (
|
||||
ErrorKindUnknown ErrorKind = "unknown"
|
||||
ErrorKindCanceled ErrorKind = "canceled"
|
||||
ErrorKindTimeout ErrorKind = "timeout"
|
||||
ErrorKindRateLimited ErrorKind = "rate_limited"
|
||||
ErrorKindUnavailable ErrorKind = "unavailable"
|
||||
ErrorKindProvider ErrorKind = "provider"
|
||||
ErrorKindUnknown ErrorKind = "unknown"
|
||||
ErrorKindCanceled ErrorKind = "canceled"
|
||||
ErrorKindTimeout ErrorKind = "timeout"
|
||||
ErrorKindRateLimited ErrorKind = "rate_limited"
|
||||
ErrorKindUnavailable ErrorKind = "unavailable"
|
||||
ErrorKindAuth ErrorKind = "auth"
|
||||
ErrorKindConfiguration ErrorKind = "configuration"
|
||||
ErrorKindProvider ErrorKind = "provider"
|
||||
)
|
||||
|
||||
// ClassifiedError is implemented by errors that expose a stable ErrorKind.
|
||||
@@ -130,6 +133,9 @@ type GeneratePolicy struct {
|
||||
Timeout time.Duration
|
||||
MaxAttempts int
|
||||
Backoff time.Duration
|
||||
// Jitter adds up to this duration of random delay to retry backoff.
|
||||
// It is opt-in so existing retry timing remains deterministic by default.
|
||||
Jitter time.Duration
|
||||
}
|
||||
|
||||
// GenerateWithRetry calls m.Generate with per-attempt timeout and bounded retry.
|
||||
@@ -183,7 +189,7 @@ func GenerateWithRetry(ctx context.Context, m Model, req *Request, policy Genera
|
||||
// Always back off between retries — exponential and capped — so an
|
||||
// opt-in retry can never become a tight loop hammering the provider,
|
||||
// even if Backoff was left at zero.
|
||||
backoff := retryBackoff(err, attempt, policy.Backoff)
|
||||
backoff := retryBackoffWithJitter(err, attempt, policy.Backoff, policy.Jitter)
|
||||
t := time.NewTimer(backoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -219,6 +225,10 @@ func generateAttempt(ctx context.Context, m Model, req *Request, opts ...Generat
|
||||
}
|
||||
|
||||
func retryBackoff(err error, attempt int, base time.Duration) time.Duration {
|
||||
return retryBackoffWithJitter(err, attempt, base, 0)
|
||||
}
|
||||
|
||||
func retryBackoffWithJitter(err error, attempt int, base, jitter time.Duration) time.Duration {
|
||||
backoff := base
|
||||
if backoff <= 0 {
|
||||
backoff = 200 * time.Millisecond
|
||||
@@ -236,6 +246,9 @@ func retryBackoff(err error, attempt int, base time.Duration) time.Duration {
|
||||
backoff = delay
|
||||
}
|
||||
}
|
||||
if jitter > 0 {
|
||||
backoff += time.Duration(rand.Int64N(int64(jitter) + 1))
|
||||
}
|
||||
if backoff > 30*time.Second {
|
||||
return 30 * time.Second
|
||||
}
|
||||
@@ -265,6 +278,10 @@ func ClassifyError(err error) ErrorKind {
|
||||
switch {
|
||||
case code == 429:
|
||||
return ErrorKindRateLimited
|
||||
case code == 401 || code == 403:
|
||||
return ErrorKindAuth
|
||||
case code == 400 || code == 404:
|
||||
return ErrorKindConfiguration
|
||||
case code >= 500:
|
||||
return ErrorKindUnavailable
|
||||
case code > 0:
|
||||
@@ -277,6 +294,10 @@ func ClassifyError(err error) ErrorKind {
|
||||
return ErrorKindRateLimited
|
||||
case strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline"):
|
||||
return ErrorKindTimeout
|
||||
case strings.Contains(msg, "unauthorized") || strings.Contains(msg, "forbidden") || strings.Contains(msg, "invalid api key") || strings.Contains(msg, "api key") || strings.Contains(msg, "credential"):
|
||||
return ErrorKindAuth
|
||||
case strings.Contains(msg, "missing") || strings.Contains(msg, "not configured") || strings.Contains(msg, "configuration") || strings.Contains(msg, "unsupported model") || strings.Contains(msg, "model not found"):
|
||||
return ErrorKindConfiguration
|
||||
case strings.Contains(msg, "temporar") || strings.Contains(msg, "unavailable"):
|
||||
return ErrorKindUnavailable
|
||||
default:
|
||||
|
||||
+17
-1
@@ -230,9 +230,13 @@ func TestClassifyErrorDistinguishesOperationalOutcomes(t *testing.T) {
|
||||
{name: "canceled", err: context.Canceled, want: ErrorKindCanceled},
|
||||
{name: "timeout", err: context.DeadlineExceeded, want: ErrorKindTimeout},
|
||||
{name: "rate limit status", err: statusErr(429), want: ErrorKindRateLimited},
|
||||
{name: "auth status", err: statusErr(401), want: ErrorKindAuth},
|
||||
{name: "configuration status", err: statusErr(400), want: ErrorKindConfiguration},
|
||||
{name: "unavailable status", err: statusErr(503), want: ErrorKindUnavailable},
|
||||
{name: "provider status", err: statusErr(400), want: ErrorKindProvider},
|
||||
{name: "provider status", err: statusErr(409), want: ErrorKindProvider},
|
||||
{name: "rate limit text", err: errors.New("rate limit exceeded"), want: ErrorKindRateLimited},
|
||||
{name: "auth text", err: errors.New("invalid API key"), want: ErrorKindAuth},
|
||||
{name: "configuration text", err: errors.New("model not found"), want: ErrorKindConfiguration},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -423,3 +427,15 @@ func TestHTTPErrorExposesStatusAndRetryAfter(t *testing.T) {
|
||||
t.Fatalf("RetryAfter() = %s, want 2s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryBackoffAddsBoundedJitter(t *testing.T) {
|
||||
const base = 10 * time.Millisecond
|
||||
const jitter = 5 * time.Millisecond
|
||||
|
||||
for range 100 {
|
||||
got := retryBackoffWithJitter(errors.New("temporary"), 1, base, jitter)
|
||||
if got < base || got > base+jitter {
|
||||
t.Fatalf("retryBackoffWithJitter() = %s, want in [%s, %s]", got, base, base+jitter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +216,7 @@ func TestConfiguredProviderStreamsSkipWithoutCredentials(t *testing.T) {
|
||||
{provider: "together", keyEnv: "TOGETHER_API_KEY", modelEnv: "TOGETHER_MODEL"},
|
||||
{provider: "atlascloud", keyEnv: "ATLASCLOUD_API_KEY", modelEnv: "ATLASCLOUD_MODEL"},
|
||||
{provider: "anthropic", keyEnv: "ANTHROPIC_API_KEY", modelEnv: "ANTHROPIC_MODEL"},
|
||||
{provider: "gemini", keyEnv: "GEMINI_API_KEY", modelEnv: "GEMINI_MODEL"},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.provider, func(t *testing.T) {
|
||||
@@ -256,24 +257,6 @@ func TestConfiguredProviderStreamsSkipWithoutCredentials(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedProvidersReturnStreamingUnsupportedAndStayUnregistered(t *testing.T) {
|
||||
for _, provider := range []string{"gemini"} {
|
||||
provider := provider
|
||||
t.Run(provider, func(t *testing.T) {
|
||||
if caps := ai.ProviderCapabilities(provider); caps.Stream {
|
||||
t.Fatalf("ProviderCapabilities(%q).Stream = true, want false", provider)
|
||||
}
|
||||
_, err := ai.New(provider, ai.WithAPIKey("test-key")).Stream(context.Background(), &ai.Request{Prompt: "Hello"})
|
||||
if !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
}
|
||||
if err != nil && strings.Contains(err.Error(), "test-key") {
|
||||
t.Fatal("streaming unsupported error leaked API key")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func conformingStreamProviders(t *testing.T) []string {
|
||||
t.Helper()
|
||||
providers := ai.RegisteredProviders("stream")
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"go-micro.dev/v6/agent"
|
||||
agentpb "go-micro.dev/v6/agent/proto"
|
||||
"go-micro.dev/v6/ai"
|
||||
clt "go-micro.dev/v6/client"
|
||||
"go-micro.dev/v6/cmd"
|
||||
@@ -189,6 +190,36 @@ func (s *session) callAgent(ctx context.Context, name, message string) (*agent.R
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// streamAgent calls an agent's StreamChat endpoint and prints chunks as they
|
||||
// arrive. Agents that do not expose StreamChat return an error; callers use that
|
||||
// signal to fall back to Agent.Chat.
|
||||
func (s *session) streamAgent(ctx context.Context, name, message string) error {
|
||||
stream, err := agentpb.NewAgentService(name, s.cl).StreamChat(ctx, &agentpb.ChatRequest{Message: message})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stream.Close()
|
||||
var reply strings.Builder
|
||||
for {
|
||||
chunk, err := stream.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if chunk == nil || chunk.Reply == "" {
|
||||
continue
|
||||
}
|
||||
fmt.Print(chunk.Reply)
|
||||
reply.WriteString(chunk.Reply)
|
||||
}
|
||||
if reply.Len() > 0 {
|
||||
fmt.Println()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildRouterPrompt creates a system prompt for the router that
|
||||
// knows about all available agents and can dispatch to them.
|
||||
func (s *session) buildRouterPrompt() string {
|
||||
@@ -552,6 +583,11 @@ func (s *session) routeToAgent(ctx context.Context, prompt string) error {
|
||||
if len(s.agents) == 1 {
|
||||
for name := range s.agents {
|
||||
fmt.Printf(" \033[35m◆\033[0m \033[2m%s\033[0m\n", name)
|
||||
if s.stream {
|
||||
if err := s.streamAgent(ctx, name, prompt); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
resp, err := s.callAgent(ctx, name, prompt)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -590,6 +626,11 @@ func (s *session) routeToAgent(ctx context.Context, prompt string) error {
|
||||
}
|
||||
|
||||
fmt.Printf(" \033[35m◆\033[0m \033[2m%s\033[0m\n", agentName)
|
||||
if s.stream {
|
||||
if err := s.streamAgent(ctx, agentName, message); err == nil {
|
||||
return ai.ToolResult{ID: call.ID, Value: map[string]string{"agent": agentName, "streamed": "true"}, Content: `{"streamed":true}`}
|
||||
}
|
||||
}
|
||||
resp, err := s.callAgent(ctx, agentName, message)
|
||||
if err != nil {
|
||||
return ai.ToolResult{ID: call.ID, Value: map[string]string{"error": err.Error()}, Content: `{"error":"` + err.Error() + `"}`}
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
goagent "go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/cmd"
|
||||
aiflow "go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
@@ -192,6 +195,23 @@ history, and no-secret fallback checks.`,
|
||||
return nil
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
Name: "resume-input",
|
||||
Usage: "Continue an input-required agent run with human input",
|
||||
ArgsUsage: "[name] [run-id]",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "input", Usage: "Human input to provide to the paused run", Required: true},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
name := c.Args().First()
|
||||
runID := c.Args().Get(1)
|
||||
if name == "" || runID == "" {
|
||||
return fmt.Errorf("usage: micro agent resume-input [name] [run-id] --input <text>")
|
||||
}
|
||||
return resumeInputRun(context.Background(), c.App.Writer, name, runID, c.String("input"))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "history",
|
||||
Usage: "Show an agent's stored conversation and run history",
|
||||
@@ -285,7 +305,7 @@ func writeRunIndex(w io.Writer, name string, runs []goagent.RunSummary, asJSON b
|
||||
func writeRunIndexBreadcrumbs(w io.Writer, name string, run goagent.RunSummary) {
|
||||
if run.Stage == "input-required" {
|
||||
fmt.Fprintf(w, " inspect: micro agent history %s %s\n", name, run.RunID)
|
||||
fmt.Fprintf(w, " input: call micro.AgentResumeInput(ctx, agent, %q, input) to continue the input-required run\n", run.RunID)
|
||||
fmt.Fprintf(w, " input: micro agent resume-input %s %s --input <text>\n", name, run.RunID)
|
||||
return
|
||||
}
|
||||
if !isResumableRunSummary(run) {
|
||||
@@ -370,3 +390,78 @@ func shortTraceID(id string) string {
|
||||
}
|
||||
return id[:12]
|
||||
}
|
||||
|
||||
type cliInputPause struct {
|
||||
OriginalMessage string `json:"original_message"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
func resumeInputRun(ctx context.Context, w io.Writer, name, runID, input string) error {
|
||||
if input == "" {
|
||||
return fmt.Errorf("input required: pass --input <text>")
|
||||
}
|
||||
cp := aiflow.StoreCheckpoint(store.DefaultStore, name)
|
||||
run, ok, err := cp.Load(ctx, runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("agent run %s not found for %q", runID, name)
|
||||
}
|
||||
if run.Status != "paused" || run.State.Stage != "input-required" {
|
||||
return fmt.Errorf("agent run %s is not waiting for human input", runID)
|
||||
}
|
||||
var pause cliInputPause
|
||||
_ = run.State.Scan(&pause)
|
||||
reply := "Human input recorded; recreate the agent with the same checkpoint store and call micro.AgentResumeInput to continue model execution."
|
||||
resp := goagent.Response{Reply: reply, Agent: name, RunID: runID, ParentID: run.ParentID}
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
run.Status = "done"
|
||||
run.State.Stage = "done"
|
||||
run.State.Data = data
|
||||
for i := range run.Steps {
|
||||
if run.Steps[i].Status == "paused" || run.Steps[i].Name == "ask" {
|
||||
run.Steps[i].Status = "done"
|
||||
run.Steps[i].Error = ""
|
||||
run.Steps[i].Result = "human input: " + input
|
||||
}
|
||||
}
|
||||
if len(run.Steps) == 0 {
|
||||
run.Steps = []aiflow.StepRecord{{Name: "ask", Status: "done", Result: "human input: " + input}}
|
||||
}
|
||||
if err := cp.Save(ctx, run); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := recordCLIResumeEvents(name, runID, run.ParentID); err != nil {
|
||||
return err
|
||||
}
|
||||
if pause.Prompt != "" {
|
||||
fmt.Fprintf(w, " Prompt: %s\n", pause.Prompt)
|
||||
}
|
||||
fmt.Fprintf(w, " Recorded input for agent %q run %s.\n", name, runID)
|
||||
fmt.Fprintf(w, " Inspect: micro inspect agent %s --limit 1\n", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func recordCLIResumeEvents(name, runID, parentID string) error {
|
||||
now := time.Now()
|
||||
scoped := store.Scope(store.DefaultStore, "agent", name)
|
||||
events := []goagent.RunEvent{
|
||||
{Time: now, RunID: runID, ParentID: parentID, Agent: name, Kind: "checkpoint", Name: "done", Status: "done"},
|
||||
{Time: now.Add(time.Nanosecond), RunID: runID, ParentID: parentID, Agent: name, Kind: "done"},
|
||||
}
|
||||
for _, e := range events {
|
||||
b, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := fmt.Sprintf("runs/%s/%020d-%s", runID, e.Time.UnixNano(), e.Kind)
|
||||
if err := scoped.Write(&store.Record{Key: key, Value: b}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -9,6 +10,8 @@ import (
|
||||
|
||||
goagent "go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/ai"
|
||||
aiflow "go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
func TestWriteRunIndexJSON(t *testing.T) {
|
||||
@@ -89,7 +92,7 @@ func TestWriteRunIndexInputRequiredUsesResumeInput(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{`micro agent history runner run-input`, `micro.AgentResumeInput(ctx, agent, "run-input", input)`} {
|
||||
for _, want := range []string{`micro agent history runner run-input`, `micro agent resume-input runner run-input --input <text>`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
@@ -137,3 +140,41 @@ func TestWriteRunHistoryHumanAndJSON(t *testing.T) {
|
||||
t.Fatalf("decoded events = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeInputRunCompletesCheckpointAndInspectSummary(t *testing.T) {
|
||||
oldStore := store.DefaultStore
|
||||
store.DefaultStore = store.NewMemoryStore()
|
||||
t.Cleanup(func() { store.DefaultStore = oldStore })
|
||||
|
||||
ctx := context.Background()
|
||||
cp := aiflow.StoreCheckpoint(store.DefaultStore, "runner")
|
||||
run := aiflow.Run{ID: "run-input", Flow: "runner", Status: "paused", State: aiflow.State{Stage: "input-required"}, Steps: []aiflow.StepRecord{{Name: "ask", Status: "paused", Error: "Which region?"}}}
|
||||
if err := run.State.Set(cliInputPause{OriginalMessage: "deploy", Prompt: "Which region?"}); err != nil {
|
||||
t.Fatalf("set pause: %v", err)
|
||||
}
|
||||
if err := cp.Save(ctx, run); err != nil {
|
||||
t.Fatalf("save checkpoint: %v", err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := resumeInputRun(ctx, &out, "runner", "run-input", "us-east-1"); err != nil {
|
||||
t.Fatalf("resumeInputRun: %v", err)
|
||||
}
|
||||
if got := out.String(); !strings.Contains(got, "Recorded input") || !strings.Contains(got, "micro inspect agent runner --limit 1") {
|
||||
t.Fatalf("output missing continuation hints:\n%s", got)
|
||||
}
|
||||
loaded, ok, err := cp.Load(ctx, "run-input")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load checkpoint ok=%v err=%v", ok, err)
|
||||
}
|
||||
if loaded.Status != "done" || loaded.State.Stage != "done" {
|
||||
t.Fatalf("loaded run status/stage = %s/%s, want done/done", loaded.Status, loaded.State.Stage)
|
||||
}
|
||||
summaries, err := goagent.ListRunSummariesWithOptions(store.DefaultStore, "runner", goagent.RunListOptions{Status: "done"})
|
||||
if err != nil {
|
||||
t.Fatalf("summaries: %v", err)
|
||||
}
|
||||
if len(summaries) != 1 || summaries[0].RunID != "run-input" || summaries[0].Status != "done" {
|
||||
t.Fatalf("summaries = %#v, want completed run-input", summaries)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ It reads durable local run history, so it works after the agent or flow has stop
|
||||
func inspectAgentFlags() []cli.Flag {
|
||||
return []cli.Flag{
|
||||
&cli.BoolFlag{Name: "json", Usage: "Print run summaries as JSON for automation"},
|
||||
&cli.StringFlag{Name: "status", Usage: "Only show runs with this status (running, done, error, refused)"},
|
||||
&cli.StringFlag{Name: "status", Usage: "Only show runs with this status (running, done, canceled, timeout, rate_limited, auth, configuration, unavailable, provider_error, error, refused)"},
|
||||
&cli.StringFlag{Name: "trace", Usage: "Only show runs whose trace id matches this full id or prefix"},
|
||||
&cli.IntFlag{Name: "limit", Usage: "Show the most recently updated N runs"},
|
||||
}
|
||||
@@ -91,6 +91,12 @@ func writeAgentInspection(w io.Writer, name string, runs []goagent.RunSummary, a
|
||||
if run.Stage != "" {
|
||||
fmt.Fprintf(w, " stage=%s", run.Stage)
|
||||
}
|
||||
if run.LastErrorKind != "" {
|
||||
fmt.Fprintf(w, " error_kind=%s", run.LastErrorKind)
|
||||
}
|
||||
if run.Spent > 0 {
|
||||
fmt.Fprintf(w, " spent=%d", run.Spent)
|
||||
}
|
||||
if run.LastError != "" {
|
||||
fmt.Fprintf(w, " error=%q", run.LastError)
|
||||
}
|
||||
@@ -106,7 +112,7 @@ func writeAgentInspection(w io.Writer, name string, runs []goagent.RunSummary, a
|
||||
func writeAgentRunBreadcrumbs(w io.Writer, name string, run goagent.RunSummary) {
|
||||
if run.Stage == "input-required" {
|
||||
fmt.Fprintf(w, " inspect: micro agent history %s %s\n", name, run.RunID)
|
||||
fmt.Fprintf(w, " input: call micro.AgentResumeInput(ctx, agent, %q, input) to continue the input-required run\n", run.RunID)
|
||||
fmt.Fprintf(w, " input: micro agent resume-input %s %s --input <text>\n", name, run.RunID)
|
||||
return
|
||||
}
|
||||
if !isResumableAgentRun(run) {
|
||||
|
||||
@@ -11,13 +11,13 @@ import (
|
||||
)
|
||||
|
||||
func TestWriteAgentInspectionIncludesActionableBreadcrumbs(t *testing.T) {
|
||||
runs := []goagent.RunSummary{{RunID: "run-1", Status: "error", Events: 4, LastKind: "tool", LastError: "boom", TraceID: "1234567890abcdef", Checkpoint: "failed", Stage: "ask"}}
|
||||
runs := []goagent.RunSummary{{RunID: "run-1", Status: "auth", Events: 4, LastKind: "model", LastError: "invalid API key", LastErrorKind: "auth", TraceID: "1234567890abcdef", Checkpoint: "failed", Stage: "ask", Spent: 7}}
|
||||
var out bytes.Buffer
|
||||
if err := writeAgentInspection(&out, "support", runs, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"Agent \"support\" runs", "run-1", "status=error", "events=4", "last=tool", "checkpoint=failed", "stage=ask", `error="boom"`, "trace=1234567890ab", `micro agent history support run-1`, `micro.AgentResume(ctx, agent, "run-1")`, `micro.ResumeStreamAsk(ctx, agent, "run-1")`} {
|
||||
for _, want := range []string{"Agent \"support\" runs", "run-1", "status=auth", "events=4", "last=model", "checkpoint=failed", "stage=ask", "error_kind=auth", `error="invalid API key"`, "trace=1234567890ab", "spent=7"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ func TestWriteAgentInspectionIncludesInputResumeBreadcrumb(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"checkpoint=paused", "stage=input-required", `micro agent history support run-input`, `micro.AgentResumeInput(ctx, agent, "run-input", input)`} {
|
||||
for _, want := range []string{"checkpoint=paused", "stage=input-required", `micro agent history support run-input`, `micro agent resume-input support run-input --input <text>`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ The built-in providers currently register these capability interfaces:
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `anthropic` | Yes | No | No | Yes | Yes |
|
||||
| `atlascloud` | Yes | Yes | Yes | Yes | No |
|
||||
| `gemini` | Yes | No | No | No | No |
|
||||
| `gemini` | Yes | No | No | Yes | No |
|
||||
| `groq` | Yes | No | No | Yes | Yes |
|
||||
| `minimax` | Yes | No | No | Yes | Yes |
|
||||
| `mistral` | Yes | No | No | Yes | Yes |
|
||||
|
||||
@@ -112,6 +112,14 @@ Useful statuses include `done`, `refused`, `timeout`, `rate_limited`, `canceled`
|
||||
and `error`. Use `--json` when you want exact timestamps, trace/span ids, and error
|
||||
kinds for a bug report.
|
||||
|
||||
When a run is paused at `stage=input-required`, continue it from the CLI and then
|
||||
inspect the completed checkpoint without writing a Go helper:
|
||||
|
||||
```sh
|
||||
micro agent resume-input support <run-id> --input "Approve deploy to us-east-1"
|
||||
micro inspect agent support --limit 1
|
||||
```
|
||||
|
||||
Run timelines are stored in the agent's state store under that agent's scoped
|
||||
state (`agent/<name>/runs/...`). The persisted timeline is recorded even without
|
||||
an OpenTelemetry exporter, so `micro inspect agent` remains useful in local
|
||||
|
||||
@@ -59,7 +59,7 @@ previous section.
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `anthropic` | ✅ Verified when configured | — Unsupported | — Unsupported | ✅ Verified when configured | ⚠️ Unverified |
|
||||
| `openai` | ✅ Verified when configured | ✅ Registered | — Unsupported | ⚠️ Unverified | ⚠️ Unverified |
|
||||
| `gemini` | ✅ Verified when configured | — Unsupported | — Unsupported | ⚠️ Unverified | ⚠️ Unverified |
|
||||
| `gemini` | ✅ Verified when configured | — Unsupported | — Unsupported | ✅ Verified when configured | ⚠️ Unverified |
|
||||
| `groq` | ✅ Verified when configured | — Unsupported | — Unsupported | ⚠️ Unverified | ⚠️ Unverified |
|
||||
| `mistral` | ✅ Verified when configured | — Unsupported | — Unsupported | ⚠️ Unverified | ⚠️ Unverified |
|
||||
| `together` | ✅ Verified when configured | — Unsupported | — Unsupported | ⚠️ Unverified | ⚠️ Unverified |
|
||||
|
||||
@@ -184,6 +184,14 @@ and timing behind the answer:
|
||||
micro inspect agent assistant
|
||||
```
|
||||
|
||||
If inspect shows `stage=input-required`, provide the missing value and inspect the
|
||||
completed run from the same local store:
|
||||
|
||||
```sh
|
||||
micro agent resume-input assistant <run-id> --input "Approve the next step"
|
||||
micro inspect agent assistant --limit 1
|
||||
```
|
||||
|
||||
If the model refuses to call tools, tighten the prompt so it explicitly
|
||||
uses the `task` service before answering.
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"go-micro.dev/v6/server"
|
||||
"go-micro.dev/v6/service"
|
||||
"go-micro.dev/v6/store"
|
||||
"go-micro.dev/v6/wrapper/x402"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
@@ -130,6 +131,13 @@ func AgentToolSpend(tool string, amount int64) AgentOption {
|
||||
return agent.ToolSpend(tool, amount)
|
||||
}
|
||||
|
||||
// AgentPayer configures the wallet/signing hook used to settle x402-paid tools.
|
||||
func AgentPayer(p x402.Payer) AgentOption { return agent.Payer(p) }
|
||||
|
||||
// AgentBudget bounds autonomous x402 payments per Ask, in the asset's smallest
|
||||
// unit (0 = unlimited).
|
||||
func AgentBudget(amount int64) AgentOption { return agent.Budget(amount) }
|
||||
|
||||
// AgentModelCallTimeout sets the timeout for each provider Generate call.
|
||||
func AgentModelCallTimeout(d time.Duration) AgentOption { return agent.ModelCallTimeout(d) }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user