Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d05e10dc50 |
+19
-33
@@ -1,41 +1,27 @@
|
||||
# Priorities
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
## Work queue (ranked)
|
||||
|
||||
### Capability — the headline (roadmap: Now / Next)
|
||||
1. **Add memory compaction controls for long-running agents** ([#4765](https://github.com/micro/go-micro/issues/4765)) — #4763 closed the live registered-agent `micro chat --stream` gap, and the recent run also over-invested in docs-link guards, AtlasCloud-specific repair, and plan/delegate edge hardening. The next highest-value user-facing gap is memory that survives beyond toy conversations without unbounded prompt growth: add opt-in compaction/summarization that preserves recent turns/tool results, stores an inspectable summary, and keeps `Ask`/stream/checkpoint behavior coherent without changing defaults.
|
||||
|
||||
1. **A2A external-client conformance** ([#4815](https://github.com/micro/go-micro/issues/4815)) — make the gateway easier for non-go-micro agents to discover and stream from by serving the well-known agent card path and spec SSE events.
|
||||
2. **AP2 mandate foundation for agent payments** ([#4841](https://github.com/micro/go-micro/issues/4841)) — add opt-in checkout/payment mandate signing and verification so A2A-carried payment authority can settle over x402 without changing defaults.
|
||||
3. **Kubernetes CRD reconciler foundation** ([#4842](https://github.com/micro/go-micro/issues/4842)) — turn the shipped alpha `Agent`, `Service`, and `Flow` CRDs into a minimally runnable native deployment path with workload reconciliation and status conditions.
|
||||
|
||||
### In flight — do not re-queue
|
||||
|
||||
_None right now._
|
||||
|
||||
### 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, provider-failure inspection, x402 buyer safety, gRPC-reflection MCP,
|
||||
MCP result conformance, and the alpha Kubernetes CRD surface. Further churn in those
|
||||
areas should be marked `needs-human` unless it unlocks a clear user-visible capability._
|
||||
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
|
||||
architecture-review pass._
|
||||
|
||||
@@ -14,11 +14,8 @@ name: "Loop: Builder"
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
# PAUSED 2026-07-12: automatic schedule disabled while the team does focused
|
||||
# 1:1 fixes. Still runnable on demand via workflow_dispatch. Re-enable by
|
||||
# uncommenting the schedule below.
|
||||
# schedule:
|
||||
# - cron: "29 * * * *"
|
||||
schedule:
|
||||
- cron: "29 * * * *"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
@@ -14,11 +14,8 @@ name: "Loop: Coherence"
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
# PAUSED 2026-07-12: automatic schedule disabled while the team does focused
|
||||
# 1:1 fixes. Still runnable on demand via workflow_dispatch. Re-enable by
|
||||
# uncommenting the schedule below.
|
||||
# schedule:
|
||||
# - cron: "0 7 * * *"
|
||||
schedule:
|
||||
- cron: "0 7 * * *"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
@@ -14,11 +14,8 @@ name: "Loop: Planner"
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
# PAUSED 2026-07-12: automatic schedule disabled while the team does focused
|
||||
# 1:1 fixes. Still runnable on demand via workflow_dispatch. Re-enable by
|
||||
# uncommenting the schedule below.
|
||||
# schedule:
|
||||
# - cron: "59 * * * *"
|
||||
schedule:
|
||||
- cron: "59 * * * *"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
@@ -12,11 +12,8 @@ name: "Loop: Release"
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
# PAUSED 2026-07-12: automatic nightly release disabled while the team does
|
||||
# focused 1:1 fixes. Cut a release on demand via workflow_dispatch. Re-enable
|
||||
# by uncommenting the schedule below.
|
||||
# schedule:
|
||||
# - cron: "0 23 * * *"
|
||||
schedule:
|
||||
- cron: "0 23 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -14,11 +14,8 @@ name: "Loop: Security"
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
# PAUSED 2026-07-12: automatic schedule disabled while the team does focused
|
||||
# 1:1 fixes. Still runnable on demand via workflow_dispatch. Re-enable by
|
||||
# uncommenting the schedule below.
|
||||
# schedule:
|
||||
# - cron: "0 6 * * 1"
|
||||
schedule:
|
||||
- cron: "0 6 * * 1"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
@@ -7,13 +7,9 @@ name: "Loop: Triage"
|
||||
# failures become fixes with no human in the middle. Gated on CODEX_TRIGGER_TOKEN.
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
# PAUSED 2026-07-12: automatic CI-failure dispatch disabled while the team
|
||||
# does focused 1:1 fixes, so failures don't auto-spawn agent tasks. Re-enable
|
||||
# by uncommenting the workflow_run trigger below.
|
||||
# workflow_run:
|
||||
# workflows: ["Harness (E2E)", "Lint", "Run Tests", "govulncheck"]
|
||||
# types: [completed]
|
||||
workflow_run:
|
||||
workflows: ["Harness (E2E)", "Lint", "Run Tests", "govulncheck"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
@@ -18,55 +18,15 @@ 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/`)
|
||||
- **A2A inbound AP2 mandate verification (opt-in)** — set `Options.AP2PublicKey` (or `a2a.WithPushURLPolicy`'s sibling `a2a.WithAP2PublicKey` for embedded handlers) and the gateway verifies AP2 payment/checkout mandates carried on incoming messages — signature and task/context binding — recording the outcome in each task's `ap2Verifications`, with the x402 settlement rail carried through for the paid path. Off by default; mandates are otherwise carried unverified. (`gateway/a2a/`)
|
||||
- **Flow human-in-the-loop pause/resume** — a flow step can suspend a run for external input with `flow.Await(key, prompt)` (or `flow.AwaitStep`): the run checkpoints with status `waiting` and `Execute` returns cleanly. `Flow.Waiting` lists suspended runs with what they await, and `Flow.ResumeWith(ctx, runID, input)` injects the input and continues from the next step. Recovery (`ResumePending`) skips waiting runs since they need input, not a restart. (`flow/`)
|
||||
- **Kubernetes reconcile core (alpha)** — `kubernetes.Reconcile(desired, observed)` decides the single action needed to converge an `Agent`/`Service`/`Flow` resource toward its Deployment (create / update / noop) and returns `Ready`/`Error` status conditions. Dependency-free (no controller-runtime / client-go) and fully unit-testable; a future operator binary supplies observed state and applies the action. (`deploy/kubernetes/`)
|
||||
- **In-process dispatch fast-path (opt-in)** — `client.LocalDispatch()` lets a unary `Call` to a service running in the same process skip the network transport and dispatch straight to that server's handlers (for raw `codec/bytes.Frame` bodies — the shape agent/MCP/flow tool calls use), running the same router, wrappers, and codecs. In a benchmark this cut an in-process call from ~545µs to ~28µs (≈20×) with ~3.6× fewer allocations. Off by default; falls back to the network path for anything it doesn't cover. (`client/`, `server/`, `internal/network/`)
|
||||
|
||||
### Changed
|
||||
- **Remote agent chat streaming** — `micro chat` now streams replies from remote agents instead of waiting for the full response. (`cmd/micro/`, `agent/`)
|
||||
- **A2A external-client conformance** — the A2A gateway now serves the Agent Card at the spec 0.3.0 `/.well-known/agent-card.json` (keeping `/.well-known/agent.json` as a legacy alias), and `message/stream` emits spec-shaped `status-update`/`artifact-update` events ending in a `final:true` status-update instead of repeated full `Task` snapshots — and never sends `result` and `error` together. Standard A2A clients (ADK, LangGraph, a2a-SDK) can now discover and stream from go-micro agents. (`gateway/a2a/`)
|
||||
|
||||
### Fixed
|
||||
- **Provider failure inspection metadata** — provider failures recorded during agent runs now retain classification metadata for inspection. (`agent/`, `ai/`)
|
||||
|
||||
### Security
|
||||
- **x402 spend-cap hardening** — the paying `Client` now refuses a 402 whose `maxAmountRequired` is not a positive integer (a swallowed parse error or negative amount previously bypassed the budget cap), and a new `Config.RequireSettlement` fails closed when a paid request is served by a verify-only facilitator that never captures funds. (`wrapper/x402/`)
|
||||
- **A2A push-notification SSRF guard** — the A2A gateway no longer delivers task push notifications to caller-supplied URLs that resolve to loopback, private, link-local (incl. cloud metadata), or unspecified addresses. Callbacks are validated when set and re-checked at dial time on the resolved IP (DNS-rebinding safe); non-http(s) schemes are rejected. `Options.AllowPushURL` (and `a2a.WithPushURLPolicy` for embedded handlers) lets operators authorize trusted in-cluster receivers. (`gateway/a2a/`)
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
+30
-41
@@ -32,55 +32,44 @@ default.
|
||||
and history, end to end.
|
||||
5. Battle-tested: works across every provider, fails safely, observable.
|
||||
|
||||
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.
|
||||
## Now — hardening
|
||||
|
||||
## Now — capability
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
## Shipped agent depth
|
||||
|
||||
## Next — reach & deployment
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
## Next — agentic depth
|
||||
|
||||
## Later — exploratory
|
||||
- **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.
|
||||
|
||||
- **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).
|
||||
## Later
|
||||
|
||||
## Ongoing — hardening & DX (background, not the headline)
|
||||
- Memory management (summarization, retrieval/RAG); human-in-the-loop pause/resume;
|
||||
richer A2A live-stream reconnection (`tasks/resubscribe`) and `input-required`
|
||||
handoffs.
|
||||
|
||||
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.
|
||||
## 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.
|
||||
|
||||
## How it's sustained
|
||||
|
||||
|
||||
+21
-65
@@ -68,79 +68,35 @@ func TestA2AStreamUsesAgentChatPathWithTools(t *testing.T) {
|
||||
t.Fatalf("stream body missing tool marker: %s", rr.Body.String())
|
||||
}
|
||||
|
||||
// The spec-shaped stream carries the answer as append artifact-update
|
||||
// deltas and closes with a completed status-update (final:true).
|
||||
var (
|
||||
text strings.Builder
|
||||
finalState string
|
||||
sawFinal bool
|
||||
)
|
||||
var final struct {
|
||||
Result struct {
|
||||
Status struct {
|
||||
State string `json:"state"`
|
||||
} `json:"status"`
|
||||
Artifacts []struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"artifacts"`
|
||||
} `json:"result"`
|
||||
Error any `json:"error"`
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(rr.Body.String()), "\n") {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "data: "))
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var ev struct {
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error any `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &ev); err != nil {
|
||||
if err := json.Unmarshal([]byte(line), &final); err != nil {
|
||||
t.Fatalf("decode event %q: %v", line, err)
|
||||
}
|
||||
if ev.Error != nil {
|
||||
t.Fatalf("event carried an error field: %+v", ev.Error)
|
||||
}
|
||||
var kind struct {
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
_ = json.Unmarshal(ev.Result, &kind)
|
||||
switch kind.Kind {
|
||||
case "artifact-update":
|
||||
var au struct {
|
||||
Artifact struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"artifact"`
|
||||
}
|
||||
_ = json.Unmarshal(ev.Result, &au)
|
||||
for _, p := range au.Artifact.Parts {
|
||||
text.WriteString(p.Text)
|
||||
}
|
||||
case "status-update":
|
||||
var su struct {
|
||||
Status struct {
|
||||
State string `json:"state"`
|
||||
} `json:"status"`
|
||||
Final bool `json:"final"`
|
||||
}
|
||||
_ = json.Unmarshal(ev.Result, &su)
|
||||
if su.Final {
|
||||
sawFinal = true
|
||||
finalState = su.Status.State
|
||||
}
|
||||
default: // opening "task" snapshot
|
||||
var task struct {
|
||||
Artifacts []struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"artifacts"`
|
||||
}
|
||||
_ = json.Unmarshal(ev.Result, &task)
|
||||
for _, a := range task.Artifacts {
|
||||
for _, p := range a.Parts {
|
||||
if p.Text != "" {
|
||||
text.WriteString(p.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawFinal || finalState != "completed" {
|
||||
t.Fatalf("want a completed final:true status-update; sawFinal=%v state=%q", sawFinal, finalState)
|
||||
if final.Error != nil {
|
||||
t.Fatalf("final event error: %+v", final.Error)
|
||||
}
|
||||
if !strings.Contains(text.String(), "a2a-stream-ok") {
|
||||
t.Fatalf("reassembled stream text missing tool marker: %q", text.String())
|
||||
if final.Result.Status.State != "completed" {
|
||||
t.Fatalf("final state = %q, want completed", final.Result.Status.State)
|
||||
}
|
||||
if len(final.Result.Artifacts) != 1 || len(final.Result.Artifacts[0].Parts) != 1 || !strings.Contains(final.Result.Artifacts[0].Parts[0].Text, "a2a-stream-ok") {
|
||||
t.Fatalf("final artifacts = %+v, want tool marker", final.Result.Artifacts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +400,6 @@ 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,8 +5,6 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -14,7 +12,6 @@ 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
|
||||
@@ -131,7 +128,6 @@ 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)
|
||||
@@ -177,64 +173,6 @@ 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.
|
||||
@@ -451,11 +389,6 @@ 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,17 +2,12 @@ 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
|
||||
@@ -186,132 +181,3 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ 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"
|
||||
)
|
||||
|
||||
@@ -60,9 +59,6 @@ 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.
|
||||
@@ -106,10 +102,6 @@ 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".
|
||||
@@ -255,18 +247,6 @@ 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.
|
||||
@@ -295,12 +275,6 @@ 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.
|
||||
|
||||
+3
-37
@@ -51,8 +51,6 @@ const (
|
||||
AttrDispatch = "agent.dispatch"
|
||||
AttrTrigger = "agent.trigger"
|
||||
AttrRunEventKind = "agent.event.kind"
|
||||
AttrSpend = "agent.spend"
|
||||
AttrToolSpend = "agent.tool.spend"
|
||||
)
|
||||
|
||||
type RunEvent struct {
|
||||
@@ -75,8 +73,6 @@ 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
|
||||
@@ -86,8 +82,7 @@ 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", "auth", "configuration", "unavailable",
|
||||
// "provider_error", "error", or "refused").
|
||||
// "rate_limited", "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
|
||||
@@ -115,7 +110,6 @@ 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 {
|
||||
@@ -367,7 +361,6 @@ 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)
|
||||
@@ -377,7 +370,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), Spent: a.spend, ToolSpend: a.spend - spentBefore})
|
||||
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)})
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -391,7 +384,6 @@ 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 {
|
||||
@@ -401,9 +393,6 @@ 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))
|
||||
}
|
||||
@@ -419,7 +408,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), Spent: a.spend, ToolSpend: toolSpend})
|
||||
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)})
|
||||
span.End()
|
||||
return res
|
||||
}
|
||||
@@ -506,12 +495,6 @@ 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))
|
||||
@@ -546,12 +529,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -638,9 +615,6 @@ 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
|
||||
@@ -688,14 +662,6 @@ 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"
|
||||
}
|
||||
|
||||
+1
-61
@@ -421,63 +421,6 @@ 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))
|
||||
@@ -720,10 +663,7 @@ 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: "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"},
|
||||
{name: "provider", kind: ai.ErrorKindProvider, want: "error"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestRegisteredProviders(t *testing.T) {
|
||||
}
|
||||
|
||||
got = ai.RegisteredProviders("stream")
|
||||
want = []string{"anthropic", "atlascloud", "gemini", "groq", "minimax", "mistral", "openai", "together"}
|
||||
want = []string{"anthropic", "atlascloud", "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, Stream: true}},
|
||||
{Provider: "gemini", Capabilities: ai.Capabilities{Model: 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", "gemini", "groq", "minimax", "mistral", "openai", "test-stream", "together"}
|
||||
want := []string{"anthropic", "atlascloud", "groq", "minimax", "mistral", "openai", "test-stream", "together"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
+4
-136
@@ -10,7 +10,6 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -26,7 +25,6 @@ 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.
|
||||
@@ -71,7 +69,9 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
})
|
||||
}
|
||||
|
||||
contents := geminiContents(req)
|
||||
contents := []map[string]any{
|
||||
{"role": "user", "parts": []map[string]any{{"text": req.Prompt}}},
|
||||
}
|
||||
|
||||
apiReq := map[string]any{
|
||||
"contents": contents,
|
||||
@@ -135,121 +135,7 @@ 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) {
|
||||
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()
|
||||
return nil, fmt.Errorf("%w: gemini provider", ai.ErrStreamingUnsupported)
|
||||
}
|
||||
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, []map[string]any, error) {
|
||||
@@ -338,21 +224,3 @@ 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
|
||||
}
|
||||
|
||||
+6
-103
@@ -2,12 +2,7 @@ package gemini
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -86,108 +81,16 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
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"},
|
||||
},
|
||||
req := &ai.Request{
|
||||
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")
|
||||
}
|
||||
|
||||
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")
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,8 +135,6 @@ 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{}
|
||||
|
||||
+7
-28
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -85,14 +84,12 @@ 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"
|
||||
ErrorKindAuth ErrorKind = "auth"
|
||||
ErrorKindConfiguration ErrorKind = "configuration"
|
||||
ErrorKindProvider ErrorKind = "provider"
|
||||
ErrorKindUnknown ErrorKind = "unknown"
|
||||
ErrorKindCanceled ErrorKind = "canceled"
|
||||
ErrorKindTimeout ErrorKind = "timeout"
|
||||
ErrorKindRateLimited ErrorKind = "rate_limited"
|
||||
ErrorKindUnavailable ErrorKind = "unavailable"
|
||||
ErrorKindProvider ErrorKind = "provider"
|
||||
)
|
||||
|
||||
// ClassifiedError is implemented by errors that expose a stable ErrorKind.
|
||||
@@ -133,9 +130,6 @@ 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.
|
||||
@@ -189,7 +183,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 := retryBackoffWithJitter(err, attempt, policy.Backoff, policy.Jitter)
|
||||
backoff := retryBackoff(err, attempt, policy.Backoff)
|
||||
t := time.NewTimer(backoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -225,10 +219,6 @@ 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
|
||||
@@ -246,9 +236,6 @@ func retryBackoffWithJitter(err error, attempt int, base, jitter time.Duration)
|
||||
backoff = delay
|
||||
}
|
||||
}
|
||||
if jitter > 0 {
|
||||
backoff += time.Duration(rand.Int64N(int64(jitter) + 1))
|
||||
}
|
||||
if backoff > 30*time.Second {
|
||||
return 30 * time.Second
|
||||
}
|
||||
@@ -278,10 +265,6 @@ 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:
|
||||
@@ -294,10 +277,6 @@ 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:
|
||||
|
||||
+1
-17
@@ -230,13 +230,9 @@ 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(409), want: ErrorKindProvider},
|
||||
{name: "provider status", err: statusErr(400), 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) {
|
||||
@@ -427,15 +423,3 @@ 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,7 +216,6 @@ 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) {
|
||||
@@ -257,6 +256,24 @@ 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")
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
raw "go-micro.dev/v6/codec/bytes"
|
||||
"go-micro.dev/v6/internal/network"
|
||||
"go-micro.dev/v6/metadata"
|
||||
"go-micro.dev/v6/transport"
|
||||
"go-micro.dev/v6/transport/headers"
|
||||
)
|
||||
|
||||
// localCall is the in-process fast-path for Call. When LocalDispatch is enabled
|
||||
// and the callee runs in this same process, a unary request whose body and
|
||||
// response are raw frames (codec/bytes.Frame) is dispatched straight to the
|
||||
// server's handlers via internal/network — no dial, no codec-over-socket,
|
||||
// no transport pump. It returns handled=false to fall back to the network path
|
||||
// for anything it does not cover (disabled, streaming, non-frame bodies, or a
|
||||
// service not registered in-process), so behavior is unchanged unless the
|
||||
// fast-path fully applies.
|
||||
func (r *rpcClient) localCall(ctx context.Context, req Request, resp interface{}) (handled bool, err error) {
|
||||
if !r.opts.LocalDispatch || req.Stream() {
|
||||
return false, nil
|
||||
}
|
||||
reqFrame, ok := req.Body().(*raw.Frame)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
respFrame, ok := resp.(*raw.Frame)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
dispatch, ok := network.Lookup(req.Service())
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
header := make(map[string]string)
|
||||
if md, ok := metadata.FromContext(ctx); ok {
|
||||
for k, v := range md {
|
||||
if k == headers.Message { // pub/sub topic header, never forwarded
|
||||
continue
|
||||
}
|
||||
header[k] = v
|
||||
}
|
||||
}
|
||||
header[headers.Request] = req.Service()
|
||||
header[headers.Endpoint] = req.Endpoint()
|
||||
header["Content-Type"] = req.ContentType()
|
||||
header["Accept"] = req.ContentType()
|
||||
|
||||
reply, err := dispatch(ctx, &transport.Message{Header: header, Body: reqFrame.Data})
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
if reply != nil {
|
||||
respFrame.Data = reply.Body
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/client"
|
||||
raw "go-micro.dev/v6/codec/bytes"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/selector"
|
||||
"go-micro.dev/v6/server"
|
||||
)
|
||||
|
||||
type EchoReq struct {
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
type EchoRsp struct {
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
type EchoHandler struct{}
|
||||
|
||||
func (EchoHandler) Echo(_ context.Context, req *EchoReq, rsp *EchoRsp) error {
|
||||
rsp.Msg = "echo:" + req.Msg
|
||||
return nil
|
||||
}
|
||||
|
||||
// startEchoServer starts a real server on the given registry and returns a stop
|
||||
// func. The server is reachable over the network transport and (via Start)
|
||||
// registered for the in-process fast-path.
|
||||
func startEchoServer(t testing.TB, reg registry.Registry) func() {
|
||||
t.Helper()
|
||||
srv := server.NewServer(
|
||||
server.Name("echo.local"),
|
||||
server.Address("127.0.0.1:0"),
|
||||
server.Registry(reg),
|
||||
)
|
||||
if err := srv.Handle(srv.NewHandler(&EchoHandler{})); err != nil {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
if err := srv.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
// Wait for registration so the client's selector can find a node.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if svcs, err := reg.GetService("echo.local"); err == nil && len(svcs) > 0 && len(svcs[0].Nodes) > 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
return func() { _ = srv.Stop() }
|
||||
}
|
||||
|
||||
func newEchoClient(reg registry.Registry, opts ...client.Option) client.Client {
|
||||
base := []client.Option{
|
||||
client.Registry(reg),
|
||||
client.Selector(selector.NewSelector(selector.Registry(reg))),
|
||||
client.ContentType("application/json"),
|
||||
}
|
||||
return client.NewClient(append(base, opts...)...)
|
||||
}
|
||||
|
||||
// callEcho makes an echo call with a raw-frame body (the shape agent/MCP/flow
|
||||
// dispatch uses) and returns the decoded reply.
|
||||
func callEcho(t testing.TB, cl client.Client, msg string) EchoRsp {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(EchoReq{Msg: msg})
|
||||
req := cl.NewRequest("echo.local", "EchoHandler.Echo", &raw.Frame{Data: body}, client.WithContentType("application/json"))
|
||||
var rsp raw.Frame
|
||||
if err := cl.Call(context.Background(), req, &rsp); err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
var out EchoRsp
|
||||
if err := json.Unmarshal(rsp.Data, &out); err != nil {
|
||||
t.Fatalf("decode reply %q: %v", rsp.Data, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestLocalDispatchMatchesNetwork proves the in-process fast-path returns the
|
||||
// exact same result as the network path for the same handler and request.
|
||||
func TestLocalDispatchMatchesNetwork(t *testing.T) {
|
||||
reg := registry.NewMemoryRegistry()
|
||||
stop := startEchoServer(t, reg)
|
||||
defer stop()
|
||||
|
||||
net := newEchoClient(reg) // network path
|
||||
local := newEchoClient(reg, client.LocalDispatch()) // in-process fast-path
|
||||
|
||||
netRsp := callEcho(t, net, "hi")
|
||||
localRsp := callEcho(t, local, "hi")
|
||||
|
||||
if netRsp.Msg != "echo:hi" {
|
||||
t.Fatalf("network reply = %q, want echo:hi", netRsp.Msg)
|
||||
}
|
||||
if localRsp != netRsp {
|
||||
t.Fatalf("fast-path reply %+v != network reply %+v", localRsp, netRsp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalDispatchFallsBackWhenNotLocal confirms a service not registered
|
||||
// in-process still works via the network path even with LocalDispatch on.
|
||||
func TestLocalDispatchFallsBackWhenNotLocal(t *testing.T) {
|
||||
reg := registry.NewMemoryRegistry()
|
||||
stop := startEchoServer(t, reg)
|
||||
defer stop()
|
||||
|
||||
// LocalDispatch is on, but the call still resolves — the fast-path only
|
||||
// engages when it fully applies, otherwise the network path runs.
|
||||
local := newEchoClient(reg, client.LocalDispatch())
|
||||
if got := callEcho(t, local, "x").Msg; got != "echo:x" {
|
||||
t.Fatalf("reply = %q, want echo:x", got)
|
||||
}
|
||||
}
|
||||
|
||||
func benchmarkEcho(b *testing.B, opts ...client.Option) {
|
||||
reg := registry.NewMemoryRegistry()
|
||||
stop := startEchoServer(b, reg)
|
||||
defer stop()
|
||||
cl := newEchoClient(reg, opts...)
|
||||
body, _ := json.Marshal(EchoReq{Msg: "hi"})
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
req := cl.NewRequest("echo.local", "EchoHandler.Echo", &raw.Frame{Data: body}, client.WithContentType("application/json"))
|
||||
var rsp raw.Frame
|
||||
if err := cl.Call(context.Background(), req, &rsp); err != nil {
|
||||
b.Fatalf("call: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkNetworkCall(b *testing.B) { benchmarkEcho(b) }
|
||||
func BenchmarkLocalCall(b *testing.B) { benchmarkEcho(b, client.LocalDispatch()) }
|
||||
@@ -68,11 +68,6 @@ type Options struct {
|
||||
PoolSize int
|
||||
PoolTTL time.Duration
|
||||
PoolCloseTimeout time.Duration
|
||||
|
||||
// LocalDispatch, when true, lets a unary Call to a service running in this
|
||||
// same process skip the network transport and dispatch directly to that
|
||||
// server's handlers (raw byte bodies only). Off by default.
|
||||
LocalDispatch bool
|
||||
}
|
||||
|
||||
// CallOptions are options used to make calls to a server.
|
||||
@@ -186,17 +181,6 @@ func ContentType(ct string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// LocalDispatch enables the in-process fast-path: a unary Call to a service
|
||||
// running in the same process dispatches straight to that server's handlers
|
||||
// (skipping dial, codec-over-socket, and the transport pump) when both request
|
||||
// and response bodies are raw frames (codec/bytes.Frame) — the shape agent,
|
||||
// MCP, and flow tool calls use. Falls back to the network path otherwise.
|
||||
func LocalDispatch() Option {
|
||||
return func(o *Options) {
|
||||
o.LocalDispatch = true
|
||||
}
|
||||
}
|
||||
|
||||
// PoolSize sets the connection pool size.
|
||||
func PoolSize(d int) Option {
|
||||
return func(o *Options) {
|
||||
|
||||
@@ -83,12 +83,6 @@ func (r *rpcClient) call(
|
||||
resp interface{},
|
||||
opts CallOptions,
|
||||
) error {
|
||||
// In-process fast-path: if the callee runs in this process and both bodies
|
||||
// are raw frames, dispatch directly and skip the network entirely.
|
||||
if handled, err := r.localCall(ctx, req, resp); handled {
|
||||
return err
|
||||
}
|
||||
|
||||
address := node.Address
|
||||
logger := r.Options().Logger
|
||||
|
||||
|
||||
@@ -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, canceled, timeout, rate_limited, auth, configuration, unavailable, provider_error, error, refused)"},
|
||||
&cli.StringFlag{Name: "status", Usage: "Only show runs with this status (running, done, 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,12 +91,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -11,13 +11,13 @@ import (
|
||||
)
|
||||
|
||||
func TestWriteAgentInspectionIncludesActionableBreadcrumbs(t *testing.T) {
|
||||
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}}
|
||||
runs := []goagent.RunSummary{{RunID: "run-1", Status: "error", Events: 4, LastKind: "tool", LastError: "boom", TraceID: "1234567890abcdef", Checkpoint: "failed", Stage: "ask"}}
|
||||
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=auth", "events=4", "last=model", "checkpoint=failed", "stage=ask", "error_kind=auth", `error="invalid API key"`, "trace=1234567890ab", "spent=7"} {
|
||||
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")`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# Kubernetes deployment foundation (alpha)
|
||||
|
||||
This package is the first opt-in Kubernetes foundation for the Go Micro lifecycle:
|
||||
`Service`, `Agent`, and `Flow` resources. It is intentionally experimental and
|
||||
additive. Nothing in the Go Micro runtime installs these resources or changes
|
||||
production defaults.
|
||||
|
||||
## What is included
|
||||
|
||||
- Alpha CRD manifests in `config/crd/` for `agents.micro.dev`,
|
||||
`services.micro.dev`, and `flows.micro.dev`.
|
||||
- A small dependency-free mapper that turns a desired Go Micro resource into the
|
||||
Kubernetes `Deployment` shape an operator reconciliation loop will own.
|
||||
- A dependency-free `Reconcile(desired, observed)` core that decides the one
|
||||
action needed to converge (create / update / noop) and the `Ready`/`Error`
|
||||
status conditions — no controller-runtime, no client-go, fully unit-testable.
|
||||
A future operator binary supplies the observed state and applies the action;
|
||||
only that adapter needs the Kubernetes client.
|
||||
- Unit tests that validate the structural CRD fragments, the Agent-to-Deployment
|
||||
mapping, and the reconcile decision/conditions.
|
||||
|
||||
## Local validation
|
||||
|
||||
```sh
|
||||
go test ./deploy/kubernetes
|
||||
```
|
||||
|
||||
If you have a Kubernetes cluster and `kubectl` available, you can also perform a
|
||||
server-side dry run of the CRDs:
|
||||
|
||||
```sh
|
||||
kubectl apply --dry-run=server -f deploy/kubernetes/config/crd/
|
||||
```
|
||||
|
||||
The manifests are `v1alpha1`; expect the API shape to evolve before this becomes
|
||||
a production operator.
|
||||
@@ -1,37 +0,0 @@
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
name: agents.micro.dev
|
||||
spec:
|
||||
group: micro.dev
|
||||
scope: Namespaced
|
||||
names:
|
||||
plural: agents
|
||||
singular: agent
|
||||
kind: Agent
|
||||
shortNames: [magent]
|
||||
versions:
|
||||
- name: v1alpha1
|
||||
served: true
|
||||
storage: true
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
type: object
|
||||
required: [spec]
|
||||
properties:
|
||||
spec:
|
||||
type: object
|
||||
required: [image]
|
||||
properties:
|
||||
image: {type: string, minLength: 1}
|
||||
command:
|
||||
type: array
|
||||
items: {type: string}
|
||||
args:
|
||||
type: array
|
||||
items: {type: string}
|
||||
replicas: {type: integer, minimum: 0}
|
||||
registry: {type: string}
|
||||
env:
|
||||
type: object
|
||||
additionalProperties: {type: string}
|
||||
@@ -1,37 +0,0 @@
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
name: flows.micro.dev
|
||||
spec:
|
||||
group: micro.dev
|
||||
scope: Namespaced
|
||||
names:
|
||||
plural: flows
|
||||
singular: flow
|
||||
kind: Flow
|
||||
shortNames: [mflow]
|
||||
versions:
|
||||
- name: v1alpha1
|
||||
served: true
|
||||
storage: true
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
type: object
|
||||
required: [spec]
|
||||
properties:
|
||||
spec:
|
||||
type: object
|
||||
required: [image]
|
||||
properties:
|
||||
image: {type: string, minLength: 1}
|
||||
command:
|
||||
type: array
|
||||
items: {type: string}
|
||||
args:
|
||||
type: array
|
||||
items: {type: string}
|
||||
replicas: {type: integer, minimum: 0}
|
||||
registry: {type: string}
|
||||
env:
|
||||
type: object
|
||||
additionalProperties: {type: string}
|
||||
@@ -1,37 +0,0 @@
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
name: services.micro.dev
|
||||
spec:
|
||||
group: micro.dev
|
||||
scope: Namespaced
|
||||
names:
|
||||
plural: services
|
||||
singular: service
|
||||
kind: Service
|
||||
shortNames: [mservice]
|
||||
versions:
|
||||
- name: v1alpha1
|
||||
served: true
|
||||
storage: true
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
type: object
|
||||
required: [spec]
|
||||
properties:
|
||||
spec:
|
||||
type: object
|
||||
required: [image]
|
||||
properties:
|
||||
image: {type: string, minLength: 1}
|
||||
command:
|
||||
type: array
|
||||
items: {type: string}
|
||||
args:
|
||||
type: array
|
||||
items: {type: string}
|
||||
replicas: {type: integer, minimum: 0}
|
||||
registry: {type: string}
|
||||
env:
|
||||
type: object
|
||||
additionalProperties: {type: string}
|
||||
@@ -1,8 +0,0 @@
|
||||
// Package kubernetes contains the experimental Kubernetes deployment foundation
|
||||
// for Go Micro services, agents, and flows.
|
||||
//
|
||||
// The package is intentionally small and additive: it exposes alpha custom
|
||||
// resource manifests and a dry-run mapper that turns a resource spec into the
|
||||
// Deployment shape an operator would reconcile. It does not install an operator
|
||||
// or change any runtime defaults.
|
||||
package kubernetes
|
||||
@@ -1,87 +0,0 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCRDManifestsAreStructural(t *testing.T) {
|
||||
for _, kind := range []Kind{KindAgent, KindService, KindFlow} {
|
||||
manifest := CRDManifests[kind]
|
||||
if manifest == "" {
|
||||
t.Fatalf("missing manifest for %s", kind)
|
||||
}
|
||||
checks := []string{
|
||||
"apiVersion: apiextensions.k8s.io/v1",
|
||||
"kind: CustomResourceDefinition",
|
||||
"group: micro.dev",
|
||||
"kind: " + string(kind),
|
||||
"name: v1alpha1",
|
||||
"served: true",
|
||||
"storage: true",
|
||||
"openAPIV3Schema:",
|
||||
"type: object",
|
||||
"required: [image]",
|
||||
}
|
||||
for _, check := range checks {
|
||||
if !strings.Contains(manifest, check) {
|
||||
t.Fatalf("%s manifest missing %q:\n%s", kind, check, manifest)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapDeploymentForAgent(t *testing.T) {
|
||||
deployment, err := MapDeployment(Resource{
|
||||
Kind: KindAgent,
|
||||
Name: "support-agent",
|
||||
Namespace: "agents",
|
||||
Spec: WorkloadSpec{
|
||||
Image: "ghcr.io/acme/support-agent:v1",
|
||||
Replicas: 2,
|
||||
Registry: "kubernetes",
|
||||
Environment: map[string]string{
|
||||
"MODEL": "gpt-5.5",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("MapDeployment returned error: %v", err)
|
||||
}
|
||||
if deployment.Name != "support-agent" || deployment.Namespace != "agents" {
|
||||
t.Fatalf("unexpected identity: %+v", deployment)
|
||||
}
|
||||
if deployment.Replicas != 2 {
|
||||
t.Fatalf("replicas = %d, want 2", deployment.Replicas)
|
||||
}
|
||||
if got := deployment.Labels["micro.dev/kind"]; got != "agent" {
|
||||
t.Fatalf("micro.dev/kind label = %q, want agent", got)
|
||||
}
|
||||
container := deployment.Pod.Container
|
||||
if container.Image != "ghcr.io/acme/support-agent:v1" {
|
||||
t.Fatalf("image = %q", container.Image)
|
||||
}
|
||||
if got := container.Environment["MICRO_REGISTRY"]; got != "kubernetes" {
|
||||
t.Fatalf("MICRO_REGISTRY = %q, want kubernetes", got)
|
||||
}
|
||||
if got := container.Environment["MODEL"]; got != "gpt-5.5" {
|
||||
t.Fatalf("MODEL = %q, want gpt-5.5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapDeploymentDefaultsAndValidation(t *testing.T) {
|
||||
deployment, err := MapDeployment(Resource{Kind: KindService, Name: "api", Spec: WorkloadSpec{Image: "api:latest"}})
|
||||
if err != nil {
|
||||
t.Fatalf("MapDeployment returned error: %v", err)
|
||||
}
|
||||
if deployment.Namespace != "default" || deployment.Replicas != 1 {
|
||||
t.Fatalf("defaults = namespace %q replicas %d", deployment.Namespace, deployment.Replicas)
|
||||
}
|
||||
|
||||
if _, err := MapDeployment(Resource{Kind: KindFlow, Name: "ingest"}); err == nil {
|
||||
t.Fatal("MapDeployment without image succeeded")
|
||||
}
|
||||
if _, err := MapDeployment(Resource{Kind: "Job", Name: "job", Spec: WorkloadSpec{Image: "job:latest"}}); err == nil {
|
||||
t.Fatal("MapDeployment with unsupported kind succeeded")
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// crdFS holds the canonical CRD manifests. They live as real YAML under
|
||||
// config/crd/ so they can be applied directly (`kubectl apply -f
|
||||
// deploy/kubernetes/config/crd/`) and are embedded here so the Go API serves
|
||||
// the exact same bytes — one source of truth, no drift.
|
||||
//
|
||||
//go:embed config/crd/agent.yaml config/crd/service.yaml config/crd/flow.yaml
|
||||
var crdFS embed.FS
|
||||
|
||||
// CRDManifests contains the alpha CRDs for Go Micro lifecycle resources, loaded
|
||||
// from the embedded config/crd/ YAML.
|
||||
var CRDManifests = map[Kind]string{
|
||||
KindAgent: mustCRD("agent"),
|
||||
KindService: mustCRD("service"),
|
||||
KindFlow: mustCRD("flow"),
|
||||
}
|
||||
|
||||
// mustCRD reads an embedded CRD manifest. The files are embedded at compile
|
||||
// time, so a read error means a build/packaging bug, not a runtime condition.
|
||||
func mustCRD(name string) string {
|
||||
b, err := crdFS.ReadFile("config/crd/" + name + ".yaml")
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("kubernetes: embedded CRD %q missing: %v", name, err))
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// Reconcile is the pure decision core an operator's reconcile loop runs: given
|
||||
// a desired resource and the currently observed cluster state, it computes the
|
||||
// one action needed to converge (create / update / nothing) plus the status
|
||||
// conditions to publish. It does not talk to a cluster — no controller-runtime,
|
||||
// no client-go — so the whole convergence decision is unit-testable. An adapter
|
||||
// binary supplies Observed from the live cluster and applies the returned
|
||||
// Action; that adapter is the only piece that needs the Kubernetes client.
|
||||
|
||||
// ActionType is the change a reconcile wants applied.
|
||||
type ActionType string
|
||||
|
||||
const (
|
||||
// ActionCreate means the workload does not exist yet and should be created.
|
||||
ActionCreate ActionType = "create"
|
||||
// ActionUpdate means the workload exists but drifts from desired.
|
||||
ActionUpdate ActionType = "update"
|
||||
// ActionNoop means the workload already matches desired.
|
||||
ActionNoop ActionType = "noop"
|
||||
)
|
||||
|
||||
// Action is the change Reconcile decided on, carrying the desired Deployment.
|
||||
type Action struct {
|
||||
Type ActionType
|
||||
Deployment Deployment
|
||||
}
|
||||
|
||||
// Observed is the current cluster state Reconcile compares against. The adapter
|
||||
// fills it from the live cluster; a nil Deployment means "not created yet".
|
||||
type Observed struct {
|
||||
// Deployment is the workload as it currently exists, or nil if absent.
|
||||
Deployment *Deployment
|
||||
// ReadyReplicas is how many pods are ready, from the live Deployment status.
|
||||
ReadyReplicas int32
|
||||
}
|
||||
|
||||
// Condition is a status condition to publish on the resource — the ready/error
|
||||
// signal for the inner-loop and deploy story. It mirrors the Kubernetes
|
||||
// condition shape without importing the API types.
|
||||
type Condition struct {
|
||||
Type string `json:"type"` // "Ready" | "Error"
|
||||
Status string `json:"status"` // "True" | "False" | "Unknown"
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// Reconcile computes the action to bring observed toward desired, plus the
|
||||
// status conditions. A spec that fails to map returns an Error condition and
|
||||
// the error (no action).
|
||||
func Reconcile(desired Resource, observed Observed) (Action, []Condition, error) {
|
||||
want, err := MapDeployment(desired)
|
||||
if err != nil {
|
||||
return Action{}, []Condition{{
|
||||
Type: "Error", Status: "True", Reason: "InvalidSpec", Message: err.Error(),
|
||||
}}, err
|
||||
}
|
||||
|
||||
var action Action
|
||||
switch {
|
||||
case observed.Deployment == nil:
|
||||
action = Action{Type: ActionCreate, Deployment: want}
|
||||
case deploymentDiffers(*observed.Deployment, want):
|
||||
action = Action{Type: ActionUpdate, Deployment: want}
|
||||
default:
|
||||
action = Action{Type: ActionNoop, Deployment: want}
|
||||
}
|
||||
|
||||
return action, conditions(want, observed), nil
|
||||
}
|
||||
|
||||
// conditions derives the Ready condition from observed state against desired.
|
||||
func conditions(want Deployment, observed Observed) []Condition {
|
||||
switch {
|
||||
case observed.Deployment == nil:
|
||||
return []Condition{{
|
||||
Type: "Ready", Status: "False", Reason: "Creating",
|
||||
Message: "workload not yet created",
|
||||
}}
|
||||
case observed.ReadyReplicas < want.Replicas:
|
||||
return []Condition{{
|
||||
Type: "Ready", Status: "False", Reason: "Progressing",
|
||||
Message: fmt.Sprintf("%d/%d replicas ready", observed.ReadyReplicas, want.Replicas),
|
||||
}}
|
||||
default:
|
||||
return []Condition{{
|
||||
Type: "Ready", Status: "True", Reason: "Available",
|
||||
Message: fmt.Sprintf("%d/%d replicas ready", observed.ReadyReplicas, want.Replicas),
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
// deploymentDiffers reports whether the observed deployment drifts from desired
|
||||
// on the fields this operator manages (replicas, container, labels). Fields the
|
||||
// cluster owns (status, cluster-assigned metadata) are intentionally ignored.
|
||||
func deploymentDiffers(current, want Deployment) bool {
|
||||
return current.Replicas != want.Replicas ||
|
||||
!reflect.DeepEqual(current.Pod.Container, want.Pod.Container) ||
|
||||
!reflect.DeepEqual(current.Labels, want.Labels)
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package kubernetes
|
||||
|
||||
import "testing"
|
||||
|
||||
func agentResource() Resource {
|
||||
return Resource{
|
||||
Kind: KindAgent,
|
||||
Name: "support",
|
||||
Namespace: "agents",
|
||||
Spec: WorkloadSpec{Image: "example/support:v1", Replicas: 2, Registry: "kubernetes"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileCreatesWhenAbsent(t *testing.T) {
|
||||
action, conds, err := Reconcile(agentResource(), Observed{Deployment: nil})
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile: %v", err)
|
||||
}
|
||||
if action.Type != ActionCreate {
|
||||
t.Fatalf("action = %q, want create", action.Type)
|
||||
}
|
||||
if action.Deployment.Name != "support" || action.Deployment.Replicas != 2 {
|
||||
t.Fatalf("desired deployment = %+v", action.Deployment)
|
||||
}
|
||||
if ready := findCondition(conds, "Ready"); ready == nil || ready.Status != "False" || ready.Reason != "Creating" {
|
||||
t.Fatalf("ready condition = %+v, want False/Creating", ready)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileNoopWhenMatchedAndReady(t *testing.T) {
|
||||
want, _ := MapDeployment(agentResource())
|
||||
action, conds, err := Reconcile(agentResource(), Observed{Deployment: &want, ReadyReplicas: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile: %v", err)
|
||||
}
|
||||
if action.Type != ActionNoop {
|
||||
t.Fatalf("action = %q, want noop", action.Type)
|
||||
}
|
||||
if ready := findCondition(conds, "Ready"); ready == nil || ready.Status != "True" || ready.Reason != "Available" {
|
||||
t.Fatalf("ready condition = %+v, want True/Available", ready)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileUpdatesOnDrift(t *testing.T) {
|
||||
current, _ := MapDeployment(agentResource())
|
||||
current.Pod.Container.Image = "example/support:v0" // stale image → drift
|
||||
action, _, err := Reconcile(agentResource(), Observed{Deployment: ¤t, ReadyReplicas: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile: %v", err)
|
||||
}
|
||||
if action.Type != ActionUpdate {
|
||||
t.Fatalf("action = %q, want update", action.Type)
|
||||
}
|
||||
if action.Deployment.Pod.Container.Image != "example/support:v1" {
|
||||
t.Fatalf("update should carry the desired image, got %q", action.Deployment.Pod.Container.Image)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileProgressingWhenUnderReplicated(t *testing.T) {
|
||||
want, _ := MapDeployment(agentResource())
|
||||
_, conds, err := Reconcile(agentResource(), Observed{Deployment: &want, ReadyReplicas: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile: %v", err)
|
||||
}
|
||||
if ready := findCondition(conds, "Ready"); ready == nil || ready.Status != "False" || ready.Reason != "Progressing" {
|
||||
t.Fatalf("ready condition = %+v, want False/Progressing", ready)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileErrorOnInvalidSpec(t *testing.T) {
|
||||
// Missing image → MapDeployment fails → Error condition, no action.
|
||||
_, conds, err := Reconcile(Resource{Kind: KindService, Name: "api"}, Observed{})
|
||||
if err == nil {
|
||||
t.Fatal("Reconcile should error on an invalid spec")
|
||||
}
|
||||
if e := findCondition(conds, "Error"); e == nil || e.Status != "True" || e.Reason != "InvalidSpec" {
|
||||
t.Fatalf("error condition = %+v, want True/InvalidSpec", e)
|
||||
}
|
||||
}
|
||||
|
||||
func findCondition(conds []Condition, typ string) *Condition {
|
||||
for i := range conds {
|
||||
if conds[i].Type == typ {
|
||||
return &conds[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// Group is the API group for the alpha Go Micro Kubernetes resources.
|
||||
Group = "micro.dev"
|
||||
// Version is the current alpha API version for the CRDs in this package.
|
||||
Version = "v1alpha1"
|
||||
)
|
||||
|
||||
// Kind identifies a Go Micro lifecycle resource that can be reconciled toward a
|
||||
// Kubernetes Deployment.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindAgent Kind = "Agent"
|
||||
KindService Kind = "Service"
|
||||
KindFlow Kind = "Flow"
|
||||
)
|
||||
|
||||
// WorkloadSpec is the common alpha spec shared by Agent, Service, and Flow CRDs.
|
||||
type WorkloadSpec struct {
|
||||
Image string `json:"image"`
|
||||
Command []string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Replicas int32 `json:"replicas,omitempty"`
|
||||
Registry string `json:"registry,omitempty"`
|
||||
Environment map[string]string `json:"env,omitempty"`
|
||||
}
|
||||
|
||||
// Resource is the minimal desired state for a Go Micro lifecycle resource.
|
||||
type Resource struct {
|
||||
Kind Kind
|
||||
Name string
|
||||
Namespace string
|
||||
Spec WorkloadSpec
|
||||
}
|
||||
|
||||
// Deployment is a small, dependency-free representation of the Kubernetes
|
||||
// Deployment fields the alpha reconciler skeleton owns.
|
||||
type Deployment struct {
|
||||
Name string
|
||||
Namespace string
|
||||
Labels map[string]string
|
||||
Replicas int32
|
||||
Pod PodTemplate
|
||||
}
|
||||
|
||||
// PodTemplate describes the pod fields emitted by MapDeployment.
|
||||
type PodTemplate struct {
|
||||
Labels map[string]string
|
||||
Container Container
|
||||
}
|
||||
|
||||
// Container describes the single Go Micro workload container.
|
||||
type Container struct {
|
||||
Name string
|
||||
Image string
|
||||
Command []string
|
||||
Args []string
|
||||
Environment map[string]string
|
||||
}
|
||||
|
||||
// MapDeployment maps a Go Micro alpha resource to the Deployment shape an
|
||||
// operator reconciliation loop would apply.
|
||||
func MapDeployment(resource Resource) (Deployment, error) {
|
||||
if resource.Kind != KindAgent && resource.Kind != KindService && resource.Kind != KindFlow {
|
||||
return Deployment{}, fmt.Errorf("unsupported kind %q", resource.Kind)
|
||||
}
|
||||
name := strings.TrimSpace(resource.Name)
|
||||
if name == "" {
|
||||
return Deployment{}, fmt.Errorf("name is required")
|
||||
}
|
||||
image := strings.TrimSpace(resource.Spec.Image)
|
||||
if image == "" {
|
||||
return Deployment{}, fmt.Errorf("spec.image is required")
|
||||
}
|
||||
|
||||
namespace := strings.TrimSpace(resource.Namespace)
|
||||
if namespace == "" {
|
||||
namespace = "default"
|
||||
}
|
||||
replicas := resource.Spec.Replicas
|
||||
if replicas == 0 {
|
||||
replicas = 1
|
||||
}
|
||||
|
||||
labels := map[string]string{
|
||||
"app.kubernetes.io/name": name,
|
||||
"app.kubernetes.io/managed-by": "go-micro",
|
||||
"micro.dev/kind": strings.ToLower(string(resource.Kind)),
|
||||
}
|
||||
env := copyMap(resource.Spec.Environment)
|
||||
if resource.Spec.Registry != "" {
|
||||
env["MICRO_REGISTRY"] = resource.Spec.Registry
|
||||
}
|
||||
|
||||
return Deployment{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
Labels: copyMap(labels),
|
||||
Replicas: replicas,
|
||||
Pod: PodTemplate{
|
||||
Labels: copyMap(labels),
|
||||
Container: Container{
|
||||
Name: name,
|
||||
Image: image,
|
||||
Command: append([]string(nil), resource.Spec.Command...),
|
||||
Args: append([]string(nil), resource.Spec.Args...),
|
||||
Environment: env,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EnvironmentKeys returns stable environment variable keys from a mapped
|
||||
// container. It is useful for deterministic validation and rendering.
|
||||
func (c Container) EnvironmentKeys() []string {
|
||||
keys := make([]string, 0, len(c.Environment))
|
||||
for key := range c.Environment {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func copyMap(in map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
+1
-4
@@ -12,7 +12,6 @@ provider-free unless the example README says otherwise.
|
||||
| Prove the maintained 0→hero path | [`support`](./support/) | `go run ./examples/support` and `go test ./examples/support` | [`zero-to-hero` guide](../internal/website/docs/guides/zero-to-hero.md) |
|
||||
| See planning and delegation | [`agent-plan-delegate`](./agent-plan-delegate/) | `go run ./examples/agent-plan-delegate` | [`plan-delegate` guide](../internal/website/docs/guides/plan-delegate.md) |
|
||||
| Expose services through MCP | [`mcp/hello`](./mcp/hello/) | follow [`mcp`](./mcp/) setup | [`mcp/crud`](./mcp/crud/) and [`mcp/workflow`](./mcp/workflow/) |
|
||||
| Try a paid tool with x402 | [`agent-x402-buyer`](./agent-x402-buyer/) | `go run ./examples/agent-x402-buyer` | [`Payments (x402)` guide](../internal/website/docs/guides/x402-payments.md) |
|
||||
| Try A2A or gRPC interop next | [`agent-demo`](./agent-demo/) plus gateway docs | run the example, then use the gateway docs | [`grpc-interop`](./grpc-interop/) |
|
||||
| Add workflow durability | [`flow-durable`](./flow-durable/) | `go run ./examples/flow-durable` | [`flow-loop`](./flow-loop/) |
|
||||
|
||||
@@ -29,9 +28,7 @@ provider-free unless the example README says otherwise.
|
||||
4. **Interop next:** use [`mcp/hello`](./mcp/hello/), [`mcp/crud`](./mcp/crud/),
|
||||
and [`mcp/workflow`](./mcp/workflow/) when you are ready to expose tools to
|
||||
external AI clients.
|
||||
5. **Paid tools:** run [`agent-x402-buyer`](./agent-x402-buyer/) to see an
|
||||
agent pay a local x402-protected tool with a mock facilitator and budget.
|
||||
6. **Workflow depth:** use [`flow-durable`](./flow-durable/) once the agent path
|
||||
5. **Workflow depth:** use [`flow-durable`](./flow-durable/) once the agent path
|
||||
needs checkpointed, resumable deterministic work.
|
||||
|
||||
## CLI wayfinding
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# Agent x402 buyer
|
||||
|
||||
This example shows an agent paying for a paid HTTP tool with x402 without using
|
||||
live funds or a live chain.
|
||||
|
||||
It starts a local paid endpoint guarded by `wrapper/x402` seller middleware and a
|
||||
mock facilitator. A deterministic mock-model agent calls that endpoint as a tool,
|
||||
receives the HTTP 402 challenge, pays with `AgentPayer`, stays inside
|
||||
`AgentBudget`, retries the request, and prints the spend recorded for the run.
|
||||
|
||||
```bash
|
||||
go run ./examples/agent-x402-buyer
|
||||
```
|
||||
|
||||
Expected output includes:
|
||||
|
||||
- the paid tool response,
|
||||
- one facilitator verify and settle call, and
|
||||
- `run spend: 7 smallest units (budget 10)`.
|
||||
|
||||
The payment token and facilitator are intentionally local development fakes. To
|
||||
settle real x402 payments, keep the same `AgentPayer` / `AgentBudget` shape but
|
||||
replace the payer with a wallet-backed implementation and configure the seller
|
||||
middleware with a hosted or self-run x402 facilitator.
|
||||
@@ -1,168 +0,0 @@
|
||||
// Agent x402 buyer — a provider-free example of an agent paying for a paid tool.
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// go run ./examples/agent-x402-buyer
|
||||
//
|
||||
// It starts a local HTTP tool protected by x402 middleware, then asks a
|
||||
// deterministic mock-model agent to call that tool. The agent receives the 402
|
||||
// challenge, uses AgentPayer and AgentBudget to pay within a local mock
|
||||
// facilitator, retries the request, and prints the run spend.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
go_micro "go-micro.dev/v6"
|
||||
"go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/store"
|
||||
"go-micro.dev/v6/wrapper/x402"
|
||||
)
|
||||
|
||||
const (
|
||||
paidToolName = "paid.market_brief"
|
||||
price = int64(7)
|
||||
paymentToken = "dev-payment-token"
|
||||
)
|
||||
|
||||
type devFacilitator struct {
|
||||
verifyCount int
|
||||
settleCount int
|
||||
}
|
||||
|
||||
func (f *devFacilitator) Verify(ctx context.Context, payment string, req x402.Requirements) (x402.Result, error) {
|
||||
f.verifyCount++
|
||||
if payment != paymentToken {
|
||||
return x402.Result{Valid: false, Reason: "unknown dev payment token"}, nil
|
||||
}
|
||||
return x402.Result{Valid: true, Payer: "dev-agent-wallet"}, nil
|
||||
}
|
||||
|
||||
func (f *devFacilitator) Settle(ctx context.Context, payment string, req x402.Requirements) (x402.Result, error) {
|
||||
f.settleCount++
|
||||
return x402.Result{Valid: true, Settlement: "dev-settlement-001"}, nil
|
||||
}
|
||||
|
||||
type devPayer struct{}
|
||||
|
||||
func (devPayer) Pay(ctx context.Context, req x402.Requirements) (string, error) {
|
||||
return paymentToken, nil
|
||||
}
|
||||
|
||||
type mockModel struct{ opts ai.Options }
|
||||
|
||||
func newMock(opts ...ai.Option) ai.Model {
|
||||
m := &mockModel{}
|
||||
_ = m.Init(opts...)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *mockModel) Init(opts ...ai.Option) error {
|
||||
for _, o := range opts {
|
||||
o(&m.opts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *mockModel) Options() ai.Options { return m.opts }
|
||||
func (m *mockModel) String() string { return "agent-x402-buyer-mock" }
|
||||
func (m *mockModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("stream not supported by agent-x402-buyer mock")
|
||||
}
|
||||
|
||||
func (m *mockModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) {
|
||||
for _, tool := range req.Tools {
|
||||
if tool.Name == paidToolName && m.opts.ToolHandler != nil {
|
||||
out := m.opts.ToolHandler(ctx, ai.ToolCall{ID: "paid-brief", Name: tool.Name, Input: map[string]any{"url": req.Prompt}})
|
||||
return &ai.Response{Answer: fmt.Sprintf("Paid tool returned: %s", out.Content)}, nil
|
||||
}
|
||||
}
|
||||
return &ai.Response{Answer: "No paid tool was available."}, nil
|
||||
}
|
||||
|
||||
func paidToolServer(fac *devFacilitator) *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
paid := x402.Middleware(x402.Config{
|
||||
PayTo: "0xMerchantDevWallet",
|
||||
Network: "base-sepolia",
|
||||
Amount: fmt.Sprint(price),
|
||||
Description: "Local market brief for the x402 buyer example",
|
||||
Facilitator: fac,
|
||||
})
|
||||
mux.Handle("/brief", paid(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"brief": "Mock demand is up 12% after the agent paid the local tool.",
|
||||
"settlement": w.Header().Get(x402.PaymentResponseHeader),
|
||||
})
|
||||
})))
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func run(w io.Writer) error {
|
||||
ai.Register("agent-x402-buyer-mock", newMock)
|
||||
|
||||
fac := &devFacilitator{}
|
||||
srv := paidToolServer(fac)
|
||||
defer srv.Close()
|
||||
|
||||
st := store.NewMemoryStore()
|
||||
buyer := agent.New(
|
||||
agent.Name("x402-buyer"),
|
||||
agent.Provider("agent-x402-buyer-mock"),
|
||||
agent.Prompt("Call the paid market brief tool when given its URL."),
|
||||
agent.WithStore(st),
|
||||
go_micro.AgentPayer(devPayer{}),
|
||||
go_micro.AgentBudget(10),
|
||||
agent.WithTool(paidToolName, "Fetch a paid market brief over HTTP", map[string]any{
|
||||
"url": map[string]any{"type": "string", "description": "Paid HTTP endpoint to call"},
|
||||
}, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
url, _ := input["url"].(string)
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(body), nil
|
||||
}),
|
||||
)
|
||||
|
||||
resp, err := buyer.Ask(context.Background(), srv.URL+"/brief")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
events, err := agent.LoadRunEvents(st, "x402-buyer", resp.RunID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var spent int64
|
||||
for _, event := range events {
|
||||
if event.Spent > spent {
|
||||
spent = event.Spent
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, "Agent x402 buyer (provider: mock, funds: local dev token)")
|
||||
fmt.Fprintln(w, strings.TrimSpace(resp.Reply))
|
||||
fmt.Fprintf(w, "facilitator verify=%d settle=%d\n", fac.verifyCount, fac.settleCount)
|
||||
fmt.Fprintf(w, "run spend: %d smallest units (budget 10)\n", spent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(os.Stdout); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
+1
-4
@@ -92,10 +92,7 @@ func (f *Flow) runStepSpan(ctx context.Context, step Step, in State) (State, int
|
||||
span.SetAttributes(attribute.String(AttrFlowVerificationStatus, "failed"))
|
||||
}
|
||||
}
|
||||
if a, ok := isAwaitInput(err); ok {
|
||||
// A suspend is normal control flow, not a step error.
|
||||
span.SetStatus(codes.Ok, "waiting: "+a.Key)
|
||||
} else if err != nil {
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetAttributes(attribute.String(AttrFlowErrorKind, string(ai.ClassifyError(err))))
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
+2
-136
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"text/template"
|
||||
@@ -111,8 +110,7 @@ type Run struct {
|
||||
Flow string `json:"flow"`
|
||||
State State `json:"state"`
|
||||
Steps []StepRecord `json:"steps"`
|
||||
Status string `json:"status"` // running | waiting | done | failed
|
||||
Await *AwaitState `json:"await,omitempty"`
|
||||
Status string `json:"status"` // running | done | failed
|
||||
Started time.Time `json:"started"`
|
||||
Updated time.Time `json:"updated"`
|
||||
}
|
||||
@@ -338,54 +336,6 @@ func LLM(prompt string) StepFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// AwaitInput is the control signal a step returns (via Await) to suspend a run
|
||||
// pending external input. runFrom recognizes it, checkpoints the run as
|
||||
// "waiting", and returns cleanly — a suspend is not a failure. ResumeWith
|
||||
// injects the input and continues.
|
||||
type AwaitInput struct {
|
||||
Key string // labels what is awaited (e.g. "approval")
|
||||
Prompt string // human-facing description of the input needed
|
||||
}
|
||||
|
||||
func (e *AwaitInput) Error() string {
|
||||
if e.Prompt != "" {
|
||||
return fmt.Sprintf("flow: awaiting input %q: %s", e.Key, e.Prompt)
|
||||
}
|
||||
return fmt.Sprintf("flow: awaiting input %q", e.Key)
|
||||
}
|
||||
|
||||
// AwaitState records, on a suspended run, what it is waiting for.
|
||||
type AwaitState struct {
|
||||
Step string `json:"step"`
|
||||
Key string `json:"key"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
}
|
||||
|
||||
func isAwaitInput(err error) (*AwaitInput, bool) {
|
||||
var a *AwaitInput
|
||||
if errors.As(err, &a) {
|
||||
return a, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Await is a StepFunc that suspends the run pending external input. The run is
|
||||
// checkpointed with status "waiting" and returned cleanly; a later call to
|
||||
// Flow.ResumeWith(ctx, runID, input) completes this step with the injected
|
||||
// input and continues to the next step. key labels what is awaited (surfaced on
|
||||
// the run and via Flow.Waiting); prompt describes the input needed.
|
||||
func Await(key, prompt string) StepFunc {
|
||||
return func(_ context.Context, in State) (State, error) {
|
||||
return in, &AwaitInput{Key: key, Prompt: prompt}
|
||||
}
|
||||
}
|
||||
|
||||
// AwaitStep is a convenience for a named await step:
|
||||
// Step{Name: name, Run: Await(key, prompt)}.
|
||||
func AwaitStep(name, key, prompt string) Step {
|
||||
return Step{Name: name, Run: Await(key, prompt)}
|
||||
}
|
||||
|
||||
// startRun begins a fresh run of the flow's steps with the given input.
|
||||
func (f *Flow) startRun(ctx context.Context, data string) (Run, error) {
|
||||
if err := validateSteps(f.opts.Steps); err != nil {
|
||||
@@ -471,79 +421,13 @@ func (f *Flow) Pending(ctx context.Context) ([]Run, error) {
|
||||
}
|
||||
var out []Run
|
||||
for _, r := range all {
|
||||
// Waiting runs need injected input (ResumeWith), not a restart, so a
|
||||
// recovery loop (ResumePending) should not pick them up.
|
||||
if r.Flow == f.name && r.Status != "done" && r.Status != "waiting" {
|
||||
if r.Flow == f.name && r.Status != "done" {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Waiting returns this flow's runs suspended awaiting external input, each with
|
||||
// its Await metadata, so a caller can prompt for and inject the needed input
|
||||
// with ResumeWith.
|
||||
func (f *Flow) Waiting(ctx context.Context) ([]Run, error) {
|
||||
if f.checkpoint == nil {
|
||||
return nil, nil
|
||||
}
|
||||
all, err := f.checkpoint.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Run
|
||||
for _, r := range all {
|
||||
if r.Flow == f.name && r.Status == "waiting" {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ResumeWith completes a suspended (waiting) run: it injects input for the
|
||||
// awaited step — the input becomes that step's output state — and continues
|
||||
// from the next step. It errors if the run is not waiting for input.
|
||||
func (f *Flow) ResumeWith(ctx context.Context, runID, input string) error {
|
||||
ctx, cancel := f.withTimeout(ctx)
|
||||
defer cancel()
|
||||
|
||||
if err := validateSteps(f.opts.Steps); err != nil {
|
||||
return err
|
||||
}
|
||||
if f.checkpoint == nil {
|
||||
return fmt.Errorf("flow %s has no checkpoint configured", f.name)
|
||||
}
|
||||
run, ok, err := f.checkpoint.Load(ctx, runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("run %s not found", runID)
|
||||
}
|
||||
if run.Status != "waiting" {
|
||||
return fmt.Errorf("run %s is not waiting for input (status %q)", runID, run.Status)
|
||||
}
|
||||
steps := f.opts.Steps
|
||||
i := stepIndex(steps, run.State.Stage)
|
||||
if i < 0 {
|
||||
return fmt.Errorf("run %s is waiting at unknown step %q", runID, run.State.Stage)
|
||||
}
|
||||
// The awaited step is satisfied by the injected input; record it done and
|
||||
// advance so runFrom re-enters at the next step.
|
||||
run.Steps[i].Status = "done"
|
||||
run.Steps[i].Result = truncate(input, 200)
|
||||
run.State.Data = []byte(input)
|
||||
if i+1 < len(steps) {
|
||||
run.State.Stage = steps[i+1].Name
|
||||
} else {
|
||||
run.State.Stage = ""
|
||||
}
|
||||
run.Await = nil
|
||||
run.Status = "running"
|
||||
_, err = f.runFrom(ctx, run)
|
||||
return err
|
||||
}
|
||||
|
||||
// runFrom executes steps from the run's current Stage to the end,
|
||||
// checkpointing before and after each step.
|
||||
func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
|
||||
@@ -580,19 +464,6 @@ func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
|
||||
out, attempts, verification, err := f.runStepSpan(ctx, step, run.State)
|
||||
run.Steps[i].Attempts = attempts
|
||||
applyVerificationRecord(&run.Steps[i], verification)
|
||||
if await, ok := isAwaitInput(err); ok {
|
||||
// Suspend the run pending external input — checkpoint and return
|
||||
// cleanly (not a failure). ResumeWith injects the input later.
|
||||
run.Steps[i].Status = "waiting"
|
||||
run.Status = "waiting"
|
||||
run.Await = &AwaitState{Step: step.Name, Key: await.Key, Prompt: await.Prompt}
|
||||
if saveErr := f.save(ctx, run); saveErr != nil {
|
||||
spanErr = saveErr
|
||||
return run, saveErr
|
||||
}
|
||||
f.log.Logf(logger.InfoLevel, "Flow %s run %s waiting for input %q at step %q", f.name, run.ID, await.Key, step.Name)
|
||||
return run, nil
|
||||
}
|
||||
if err != nil {
|
||||
spanErr = err
|
||||
run.Steps[i].Status = "failed"
|
||||
@@ -666,11 +537,6 @@ func (f *Flow) runStep(ctx context.Context, step Step, in State) (State, int, Ve
|
||||
attemptCtx = ai.WithRunInfo(ctx, info)
|
||||
}
|
||||
out, err := step.Run(attemptCtx, in)
|
||||
// An await signal is control flow, not a failure: suspend immediately
|
||||
// without retrying or grading.
|
||||
if _, ok := isAwaitInput(err); ok {
|
||||
return in, attempt, lastVerification, err
|
||||
}
|
||||
if err == nil && step.Verify != nil {
|
||||
lastVerification, err = step.Verify(attemptCtx, out)
|
||||
if err == nil && !lastVerification.Passed {
|
||||
|
||||
@@ -87,93 +87,6 @@ func TestFlowCheckpointResume(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowAwaitAndResumeWith(t *testing.T) {
|
||||
mem := store.NewMemoryStore()
|
||||
var firstCalls int
|
||||
var secondInput string
|
||||
|
||||
steps := []Step{
|
||||
{Name: "first", Run: func(_ context.Context, in State) (State, error) {
|
||||
firstCalls++
|
||||
in.Data = []byte("first-done")
|
||||
return in, nil
|
||||
}},
|
||||
AwaitStep("approval", "approve", "Approve to continue?"),
|
||||
{Name: "second", Run: func(_ context.Context, in State) (State, error) {
|
||||
secondInput = in.String()
|
||||
in.Data = []byte("second-done")
|
||||
return in, nil
|
||||
}},
|
||||
}
|
||||
|
||||
f := New("hitl", WithCheckpoint(StoreCheckpoint(mem, "hitl")), Steps(steps...))
|
||||
|
||||
// Execute suspends at the await step — a clean return, not an error.
|
||||
if err := f.Execute(context.Background(), "start"); err != nil {
|
||||
t.Fatalf("Execute should suspend cleanly, got %v", err)
|
||||
}
|
||||
if firstCalls != 1 {
|
||||
t.Fatalf("first step calls = %d, want 1", firstCalls)
|
||||
}
|
||||
|
||||
// A waiting run is not pending (restart), it needs input.
|
||||
if pend, _ := f.Pending(context.Background()); len(pend) != 0 {
|
||||
t.Errorf("a waiting run must not be pending, got %d", len(pend))
|
||||
}
|
||||
waiting, err := f.Waiting(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(waiting) != 1 {
|
||||
t.Fatalf("waiting runs = %d, want 1", len(waiting))
|
||||
}
|
||||
w := waiting[0]
|
||||
if w.Status != "waiting" || w.Await == nil || w.Await.Key != "approve" ||
|
||||
w.Await.Prompt != "Approve to continue?" || w.Await.Step != "approval" {
|
||||
t.Fatalf("await metadata = %+v (status %q)", w.Await, w.Status)
|
||||
}
|
||||
if w.State.Stage != "approval" {
|
||||
t.Fatalf("waiting stage = %q, want approval", w.State.Stage)
|
||||
}
|
||||
|
||||
// Injecting input completes the awaited step and runs the rest.
|
||||
if err := f.ResumeWith(context.Background(), w.ID, "approved"); err != nil {
|
||||
t.Fatalf("ResumeWith: %v", err)
|
||||
}
|
||||
if firstCalls != 1 {
|
||||
t.Errorf("completed step re-ran on resume; first calls = %d", firstCalls)
|
||||
}
|
||||
if secondInput != "approved" {
|
||||
t.Errorf("second step input = %q, want the injected 'approved'", secondInput)
|
||||
}
|
||||
if wr, _ := f.Waiting(context.Background()); len(wr) != 0 {
|
||||
t.Errorf("no waiting runs after resume, got %d", len(wr))
|
||||
}
|
||||
runs, _ := StoreCheckpoint(mem, "hitl").List(context.Background())
|
||||
if len(runs) != 1 || runs[0].Status != "done" {
|
||||
t.Fatalf("run should be done after resume, got %+v", runs)
|
||||
}
|
||||
if runs[0].Await != nil {
|
||||
t.Errorf("await metadata should be cleared after resume, got %+v", runs[0].Await)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowResumeWithRejectsNonWaiting(t *testing.T) {
|
||||
mem := store.NewMemoryStore()
|
||||
f := New("hitl2", WithCheckpoint(StoreCheckpoint(mem, "hitl2")),
|
||||
Steps(Step{Name: "only", Run: func(_ context.Context, in State) (State, error) { return in, nil }}))
|
||||
if err := f.Execute(context.Background(), "x"); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
runs, _ := StoreCheckpoint(mem, "hitl2").List(context.Background())
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("runs = %d", len(runs))
|
||||
}
|
||||
if err := f.ResumeWith(context.Background(), runs[0].ID, "input"); err == nil {
|
||||
t.Error("ResumeWith on a completed (non-waiting) run should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowStepContextIncludesRunInfo(t *testing.T) {
|
||||
var got ai.RunInfo
|
||||
step := Step{Name: "inspect", Run: func(ctx context.Context, in State) (State, error) {
|
||||
|
||||
+44
-209
@@ -27,14 +27,12 @@ package a2a
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -66,20 +64,6 @@ type Options struct {
|
||||
Client client.Client
|
||||
// Logger for startup/debug output (defaults to log.Default()).
|
||||
Logger *log.Logger
|
||||
// AllowPushURL authorizes an outbound push-notification callback URL
|
||||
// (tasks/pushNotificationConfig/set). Return a non-nil error to reject it.
|
||||
// When nil, a default SSRF-safe policy applies: only http/https URLs whose
|
||||
// host does not resolve to a loopback, private, link-local, or unspecified
|
||||
// address are allowed, and the connection is pinned to that check at dial
|
||||
// time (DNS-rebinding safe). Set this to permit a trusted in-cluster
|
||||
// receiver, or to narrow delivery to an allowlist.
|
||||
AllowPushURL func(*url.URL) error
|
||||
// AP2PublicKey, when set, verifies AP2 payment/checkout mandates carried on
|
||||
// incoming A2A messages against this Ed25519 key and records the outcome in
|
||||
// each task's ap2Verifications (signature + task/context binding). When
|
||||
// unset, mandates are carried through unverified. This is opt-in so the
|
||||
// default flow stays free of a payment trust decision.
|
||||
AP2PublicKey ed25519.PublicKey
|
||||
}
|
||||
|
||||
// Gateway serves the A2A protocol over HTTP for the registry's agents.
|
||||
@@ -103,20 +87,7 @@ func New(opts Options) *Gateway {
|
||||
opts.BaseURL = "http://localhost" + opts.Address
|
||||
}
|
||||
opts.BaseURL = strings.TrimRight(opts.BaseURL, "/")
|
||||
g := &Gateway{opts: opts, disp: newDispatcher()}
|
||||
if opts.AllowPushURL != nil {
|
||||
// Operator owns the trust decision: use their policy and skip the
|
||||
// built-in private-IP dial guard so trusted in-cluster hosts resolve.
|
||||
g.disp.allowPushURL = opts.AllowPushURL
|
||||
g.disp.guardPushDial = false
|
||||
}
|
||||
if len(opts.AP2PublicKey) > 0 {
|
||||
pub := opts.AP2PublicKey
|
||||
g.disp.ap2Verify = func(s AP2SignedMandate, task Task) AP2Verification {
|
||||
return VerifyAP2ForTask(s, pub, task, nil)
|
||||
}
|
||||
}
|
||||
return g
|
||||
return &Gateway{opts: opts, disp: newDispatcher()}
|
||||
}
|
||||
|
||||
// Invoke runs an agent for one message and returns its reply. It is the
|
||||
@@ -127,55 +98,16 @@ type Invoke func(ctx context.Context, text string) (string, error)
|
||||
// StreamInvoke runs an agent for one message and returns streaming output chunks.
|
||||
type StreamInvoke func(ctx context.Context, text string) (ai.Stream, error)
|
||||
|
||||
// AgentHandlerOption configures an embedded A2A agent handler.
|
||||
type AgentHandlerOption func(*dispatcher)
|
||||
|
||||
// WithPushURLPolicy sets the push-notification callback URL policy for an
|
||||
// embedded agent handler (the analog of Options.AllowPushURL on the gateway).
|
||||
// Return a non-nil error to reject a URL. Without it, the default SSRF-safe
|
||||
// policy applies. Supplying a policy also disables the built-in private-IP dial
|
||||
// guard, so a trusted in-cluster receiver resolves.
|
||||
func WithPushURLPolicy(allow func(*url.URL) error) AgentHandlerOption {
|
||||
return func(d *dispatcher) {
|
||||
if allow == nil {
|
||||
return
|
||||
}
|
||||
d.allowPushURL = allow
|
||||
d.guardPushDial = false
|
||||
}
|
||||
}
|
||||
|
||||
// WithAP2PublicKey verifies AP2 mandates carried on incoming messages against
|
||||
// pub (the embedded-handler analog of Options.AP2PublicKey), recording the
|
||||
// outcome in each task's ap2Verifications. Without it, mandates are carried
|
||||
// unverified.
|
||||
func WithAP2PublicKey(pub ed25519.PublicKey) AgentHandlerOption {
|
||||
return func(d *dispatcher) {
|
||||
if len(pub) == 0 {
|
||||
return
|
||||
}
|
||||
d.ap2Verify = func(s AP2SignedMandate, task Task) AP2Verification {
|
||||
return VerifyAP2ForTask(s, pub, task, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewAgentHandler returns an http.Handler that serves the A2A protocol
|
||||
// for a single agent: its Agent Card at / and /.well-known/agent.json,
|
||||
// and the JSON-RPC endpoint at /. invoke runs the agent. This is what an
|
||||
// agent embeds to speak A2A directly, without a separate gateway.
|
||||
func NewAgentHandler(card AgentCard, invoke Invoke, opts ...AgentHandlerOption) http.Handler {
|
||||
func NewAgentHandler(card AgentCard, invoke Invoke) http.Handler {
|
||||
d := newDispatcher()
|
||||
for _, o := range opts {
|
||||
o(d)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
card.URL = strings.TrimRight(card.URL, "/")
|
||||
serveCard := func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, card) }
|
||||
mux.HandleFunc("GET /{$}", serveCard)
|
||||
// A2A 0.3.0 discovery is /.well-known/agent-card.json; agent.json is the
|
||||
// pre-0.3 alias, kept so existing clients don't break.
|
||||
mux.HandleFunc("GET /.well-known/agent-card.json", serveCard)
|
||||
mux.HandleFunc("GET /.well-known/agent.json", serveCard)
|
||||
mux.HandleFunc("POST /{$}", func(w http.ResponseWriter, r *http.Request) { d.serve(w, r, invoke) })
|
||||
return mux
|
||||
@@ -183,16 +115,12 @@ func NewAgentHandler(card AgentCard, invoke Invoke, opts ...AgentHandlerOption)
|
||||
|
||||
// NewAgentStreamHandler is like NewAgentHandler, but serves A2A message/stream
|
||||
// by forwarding model chunks as server-sent task updates when stream is non-nil.
|
||||
func NewAgentStreamHandler(card AgentCard, invoke Invoke, stream StreamInvoke, opts ...AgentHandlerOption) http.Handler {
|
||||
func NewAgentStreamHandler(card AgentCard, invoke Invoke, stream StreamInvoke) http.Handler {
|
||||
d := newDispatcher()
|
||||
for _, o := range opts {
|
||||
o(d)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
card.URL = strings.TrimRight(card.URL, "/")
|
||||
serveCard := func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, card) }
|
||||
mux.HandleFunc("GET /{$}", serveCard)
|
||||
mux.HandleFunc("GET /.well-known/agent-card.json", serveCard)
|
||||
mux.HandleFunc("GET /.well-known/agent.json", serveCard)
|
||||
mux.HandleFunc("POST /{$}", func(w http.ResponseWriter, r *http.Request) { d.serveWithStream(w, r, invoke, stream) })
|
||||
return mux
|
||||
@@ -211,19 +139,15 @@ func (g *Gateway) Handler() http.Handler {
|
||||
// Discovery: a directory of all agent cards.
|
||||
mux.HandleFunc("GET /agents", g.handleList)
|
||||
// Per-agent card (served at the agent's url and at its well-known path).
|
||||
// A2A 0.3.0 uses agent-card.json; agent.json is the pre-0.3 alias.
|
||||
mux.HandleFunc("GET /agents/{name}", g.handleCard)
|
||||
mux.HandleFunc("GET /agents/{name}/.well-known/agent-card.json", g.handleCard)
|
||||
mux.HandleFunc("GET /agents/{name}/.well-known/agent.json", g.handleCard)
|
||||
mux.HandleFunc("GET /agents/{name}/skills/{skill}", g.handleSkillCard)
|
||||
mux.HandleFunc("GET /agents/{name}/skills/{skill}/.well-known/agent-card.json", g.handleSkillCard)
|
||||
mux.HandleFunc("GET /agents/{name}/skills/{skill}/.well-known/agent.json", g.handleSkillCard)
|
||||
// Per-agent JSON-RPC endpoint.
|
||||
mux.HandleFunc("POST /agents/{name}", g.handleRPC)
|
||||
mux.HandleFunc("POST /agents/{name}/skills/{skill}", g.handleSkillRPC)
|
||||
// Top-level well-known: serve the single agent's card if there's
|
||||
// exactly one, otherwise point to the directory.
|
||||
mux.HandleFunc("GET /.well-known/agent-card.json", g.handleWellKnown)
|
||||
mux.HandleFunc("GET /.well-known/agent.json", g.handleWellKnown)
|
||||
return mux
|
||||
}
|
||||
@@ -298,39 +222,6 @@ type Artifact struct {
|
||||
Parts []Part `json:"parts"`
|
||||
}
|
||||
|
||||
// TaskStatusUpdateEvent is an A2A streaming event reporting a change in a
|
||||
// task's status. External SSE clients parse stream events by `kind` and stop
|
||||
// on the event whose `final` is true — a full Task snapshot (which older
|
||||
// versions emitted) carries neither, so strict clients never terminate.
|
||||
type TaskStatusUpdateEvent struct {
|
||||
TaskID string `json:"taskId"`
|
||||
ContextID string `json:"contextId"`
|
||||
Kind string `json:"kind"` // "status-update"
|
||||
Status TaskStatus `json:"status"`
|
||||
Final bool `json:"final"`
|
||||
}
|
||||
|
||||
// TaskArtifactUpdateEvent is an A2A streaming event carrying an artifact (or,
|
||||
// with Append, one incremental chunk of one).
|
||||
type TaskArtifactUpdateEvent struct {
|
||||
TaskID string `json:"taskId"`
|
||||
ContextID string `json:"contextId"`
|
||||
Kind string `json:"kind"` // "artifact-update"
|
||||
Artifact Artifact `json:"artifact"`
|
||||
Append bool `json:"append,omitempty"`
|
||||
LastChunk bool `json:"lastChunk,omitempty"`
|
||||
}
|
||||
|
||||
func statusUpdateEvent(t *Task, final bool) TaskStatusUpdateEvent {
|
||||
return TaskStatusUpdateEvent{
|
||||
TaskID: t.ID,
|
||||
ContextID: t.ContextID,
|
||||
Kind: "status-update",
|
||||
Status: t.Status,
|
||||
Final: final,
|
||||
}
|
||||
}
|
||||
|
||||
// Task is the unit of work returned by message/send and tasks/get.
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
@@ -578,26 +469,10 @@ type dispatcher struct {
|
||||
pushConfigs map[string]PushNotificationConfig
|
||||
watchers map[string]map[chan *Task]struct{}
|
||||
order []string // task ids in insertion order, for bounded eviction
|
||||
|
||||
// allowPushURL authorizes an outbound push-notification callback URL; nil
|
||||
// means the default SSRF-safe policy. guardPushDial applies the private-IP
|
||||
// dial guard (on unless an operator supplied a custom policy).
|
||||
allowPushURL func(*url.URL) error
|
||||
guardPushDial bool
|
||||
|
||||
// ap2Verify, when non-nil, verifies each AP2 mandate carried on a task and
|
||||
// records the result in the task's AP2Verifications. Nil = carry unverified.
|
||||
ap2Verify func(AP2SignedMandate, Task) AP2Verification
|
||||
}
|
||||
|
||||
func newDispatcher() *dispatcher {
|
||||
return &dispatcher{
|
||||
tasks: map[string]*Task{},
|
||||
pushConfigs: map[string]PushNotificationConfig{},
|
||||
watchers: map[string]map[chan *Task]struct{}{},
|
||||
allowPushURL: defaultPushURLPolicy,
|
||||
guardPushDial: true,
|
||||
}
|
||||
return &dispatcher{tasks: map[string]*Task{}, pushConfigs: map[string]PushNotificationConfig{}, watchers: map[string]map[chan *Task]struct{}{}}
|
||||
}
|
||||
|
||||
func (d *dispatcher) serve(w http.ResponseWriter, r *http.Request, invoke Invoke) {
|
||||
@@ -659,11 +534,14 @@ func (d *dispatcher) stream(ctx context.Context, w http.ResponseWriter, req rpcR
|
||||
writeRPC(w, req.ID, nil, e)
|
||||
return
|
||||
}
|
||||
enc, flush := sseResponse(w)
|
||||
// The Task snapshot first (carries ids and the final artifact), then a
|
||||
// terminal status-update so external SSE clients see `final:true` and stop.
|
||||
writeSSE(enc, flush, req.ID, task)
|
||||
writeSSE(enc, flush, req.ID, statusUpdateEvent(task, true))
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(sseWriter{w: w}).Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: task})
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dispatcher) streamChunks(ctx context.Context, w http.ResponseWriter, req rpcRequest, invoke StreamInvoke, fallback Invoke) {
|
||||
@@ -687,53 +565,46 @@ func (d *dispatcher) streamChunks(ctx context.Context, w http.ResponseWriter, re
|
||||
return
|
||||
}
|
||||
defer stream.Close()
|
||||
enc, flush := sseResponse(w)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
enc := json.NewEncoder(sseWriter{w: w})
|
||||
flush := func() {
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
taskID := uuid.New().String()
|
||||
contextID := p.Message.ContextID
|
||||
if contextID == "" {
|
||||
contextID = uuid.New().String()
|
||||
}
|
||||
// One artifact id for the whole stream so append:true chunks target it.
|
||||
artifactID := uuid.New().String()
|
||||
|
||||
// Open with the Task snapshot (working) so the client learns the ids.
|
||||
initial := taskFromReplyWithIDs(p.Message, "", stateWorking, taskID, contextID)
|
||||
d.store(initial)
|
||||
writeSSE(enc, flush, req.ID, initial)
|
||||
|
||||
var reply strings.Builder
|
||||
for {
|
||||
chunk, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
task := taskFromReplyWithIDs(p.Message, reply.String(), stateCompleted, taskID, contextID)
|
||||
d.store(task)
|
||||
// Spec-shaped terminal: a status-update with final:true — not a
|
||||
// full Task snapshot, which carries no terminal marker.
|
||||
writeSSE(enc, flush, req.ID, statusUpdateEvent(task, true))
|
||||
_ = enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: task})
|
||||
flush()
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
task := taskFromReplyWithIDs(p.Message, "error: "+err.Error(), stateFailed, taskID, contextID)
|
||||
d.store(task)
|
||||
// A failed status-update (final) — never `result` and `error`
|
||||
// together in one response, which strict clients reject.
|
||||
writeSSE(enc, flush, req.ID, statusUpdateEvent(task, true))
|
||||
_ = enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: task, Error: &rpcError{Code: errInternal, Message: err.Error()}})
|
||||
flush()
|
||||
return
|
||||
}
|
||||
if chunk == nil || chunk.Reply == "" {
|
||||
continue
|
||||
}
|
||||
reply.WriteString(chunk.Reply)
|
||||
// Emit the delta as an append artifact-update; keep the stored task
|
||||
// current for tasks/get and resubscribe watchers.
|
||||
d.store(taskFromReplyWithIDs(p.Message, reply.String(), stateWorking, taskID, contextID))
|
||||
writeSSE(enc, flush, req.ID, TaskArtifactUpdateEvent{
|
||||
TaskID: taskID,
|
||||
ContextID: contextID,
|
||||
Kind: "artifact-update",
|
||||
Artifact: Artifact{ArtifactID: artifactID, Parts: []Part{{Kind: "text", Text: chunk.Reply}}},
|
||||
Append: true,
|
||||
})
|
||||
task := taskFromReplyWithIDs(p.Message, reply.String(), stateWorking, taskID, contextID)
|
||||
d.store(task)
|
||||
_ = enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: task})
|
||||
flush()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -782,16 +653,20 @@ func (d *dispatcher) resubscribe(ctx context.Context, w http.ResponseWriter, req
|
||||
}
|
||||
defer unsubscribe()
|
||||
|
||||
enc, flush := sseResponse(w)
|
||||
writeEvent := func(t *Task) bool {
|
||||
writeSSE(enc, flush, req.ID, t)
|
||||
if isTerminal(t.Status.State) {
|
||||
// Close the stream with a spec-shaped terminal marker so external
|
||||
// clients see `final:true`.
|
||||
writeSSE(enc, flush, req.ID, statusUpdateEvent(t, true))
|
||||
return true
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
enc := json.NewEncoder(sseWriter{w: w})
|
||||
flush := func() {
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
return false
|
||||
}
|
||||
writeEvent := func(t *Task) bool {
|
||||
_ = enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: t})
|
||||
flush()
|
||||
return isTerminal(t.Status.State)
|
||||
}
|
||||
if writeEvent(task) {
|
||||
return
|
||||
@@ -835,11 +710,6 @@ func (d *dispatcher) setPushConfig(w http.ResponseWriter, req rpcRequest) {
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errInvalidParams, Message: "invalid params"})
|
||||
return
|
||||
}
|
||||
// Reject SSRF-unsafe callback targets before storing them.
|
||||
if err := d.checkPushURL(p.PushNotificationConfig.URL); err != nil {
|
||||
writeRPC(w, req.ID, nil, &rpcError{Code: errInvalidParams, Message: "push notification url not allowed"})
|
||||
return
|
||||
}
|
||||
d.mu.Lock()
|
||||
task := d.tasks[p.ID]
|
||||
if task != nil {
|
||||
@@ -895,15 +765,6 @@ func (g *Gateway) callAgent(ctx context.Context, name, message string) (string,
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (d *dispatcher) store(t *Task) {
|
||||
// Verify any AP2 mandates carried on the task (opt-in) and surface the
|
||||
// outcome so a downstream paid path can trust — or reject — the mandate.
|
||||
if d.ap2Verify != nil && len(t.AP2Mandates) > 0 && len(t.AP2Verifications) == 0 {
|
||||
v := make([]AP2Verification, 0, len(t.AP2Mandates))
|
||||
for _, m := range t.AP2Mandates {
|
||||
v = append(v, d.ap2Verify(m, *t))
|
||||
}
|
||||
t.AP2Verifications = v
|
||||
}
|
||||
d.mu.Lock()
|
||||
_, exists := d.tasks[t.ID]
|
||||
d.tasks[t.ID] = t
|
||||
@@ -1017,11 +878,6 @@ func (d *dispatcher) deliverPush(taskID string, task *Task) {
|
||||
if !ok || cfg.URL == "" || task == nil {
|
||||
return
|
||||
}
|
||||
// Defense in depth: re-validate the callback URL at delivery time in case
|
||||
// the policy tightened or the config was set before it applied.
|
||||
if err := d.checkPushURL(cfg.URL); err != nil {
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(task)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -1036,7 +892,7 @@ func (d *dispatcher) deliverPush(taskID string, task *Task) {
|
||||
if cfg.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.Token)
|
||||
}
|
||||
resp, err := d.pushClient().Do(req)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err == nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
@@ -1173,27 +1029,6 @@ func requestContext(parent context.Context) context.Context {
|
||||
return ctx
|
||||
}
|
||||
|
||||
// sseResponse writes the SSE response headers and returns an encoder and a
|
||||
// flush func for emitting `data:`-framed JSON-RPC events.
|
||||
func sseResponse(w http.ResponseWriter) (*json.Encoder, func()) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
enc := json.NewEncoder(sseWriter{w: w})
|
||||
return enc, func() {
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeSSE emits one JSON-RPC event (result only — never with an error) and flushes.
|
||||
func writeSSE(enc *json.Encoder, flush func(), id json.RawMessage, result any) {
|
||||
_ = enc.Encode(rpcResponse{JSONRPC: "2.0", ID: id, Result: result})
|
||||
flush()
|
||||
}
|
||||
|
||||
type sseWriter struct {
|
||||
w http.ResponseWriter
|
||||
}
|
||||
|
||||
+100
-201
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -100,38 +99,6 @@ func TestAgentCardFromRegistry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A2A 0.3.0 discovery is /.well-known/agent-card.json. The card must be
|
||||
// reachable there (canonical) as well as at the legacy agent.json alias, both
|
||||
// per-agent and at the single-agent top level.
|
||||
func TestAgentCardCanonicalWellKnownPath(t *testing.T) {
|
||||
ts, cleanup := newGatewayWithAgent(t)
|
||||
defer cleanup()
|
||||
|
||||
for _, path := range []string{
|
||||
"/agents/echo/.well-known/agent-card.json",
|
||||
"/agents/echo/.well-known/agent.json",
|
||||
"/agents/echo/skills/task/.well-known/agent-card.json",
|
||||
} {
|
||||
resp, err := http.Get(ts.URL + path)
|
||||
if err != nil {
|
||||
t.Fatalf("get %s: %v", path, err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
t.Fatalf("%s status = %d, want 200", path, resp.StatusCode)
|
||||
}
|
||||
var card AgentCard
|
||||
if err := json.NewDecoder(resp.Body).Decode(&card); err != nil {
|
||||
resp.Body.Close()
|
||||
t.Fatalf("%s decode card: %v", path, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if card.Name != "echo" {
|
||||
t.Errorf("%s card name = %q, want echo", path, card.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillEndpointServesFocusedCardAndRoutesRPC(t *testing.T) {
|
||||
ts, cleanup := newGatewayWithAgent(t)
|
||||
defer cleanup()
|
||||
@@ -224,10 +191,6 @@ func TestMessageSendContinuesExistingTask(t *testing.T) {
|
||||
|
||||
func TestPushNotificationConfigDeliversTaskUpdates(t *testing.T) {
|
||||
d := newDispatcher()
|
||||
// The test receiver is a loopback httptest server; authorize it the way a
|
||||
// deployment would authorize a trusted in-cluster push receiver.
|
||||
d.allowPushURL = func(*url.URL) error { return nil }
|
||||
d.guardPushDial = false
|
||||
updates := make(chan Task, 2)
|
||||
push := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer secret" {
|
||||
@@ -372,67 +335,6 @@ func (s *sliceStream) Recv() (*ai.Response, error) {
|
||||
|
||||
func (s *sliceStream) Close() error { return nil }
|
||||
|
||||
// streamEvent is one decoded SSE JSON-RPC event from a message/stream response.
|
||||
// A2A streams carry heterogeneous results (Task, status-update, artifact-update)
|
||||
// discriminated by `kind`, so we keep the raw result and decode on demand.
|
||||
type streamEvent struct {
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
|
||||
func (e streamEvent) kind() string {
|
||||
var k struct {
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
_ = json.Unmarshal(e.Result, &k)
|
||||
return k.Kind
|
||||
}
|
||||
|
||||
func (e streamEvent) task(t *testing.T) Task {
|
||||
t.Helper()
|
||||
var task Task
|
||||
if err := json.Unmarshal(e.Result, &task); err != nil {
|
||||
t.Fatalf("decode task event: %v", err)
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
func (e streamEvent) status(t *testing.T) TaskStatusUpdateEvent {
|
||||
t.Helper()
|
||||
var s TaskStatusUpdateEvent
|
||||
if err := json.Unmarshal(e.Result, &s); err != nil {
|
||||
t.Fatalf("decode status-update event: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (e streamEvent) artifactUpdate(t *testing.T) TaskArtifactUpdateEvent {
|
||||
t.Helper()
|
||||
var a TaskArtifactUpdateEvent
|
||||
if err := json.Unmarshal(e.Result, &a); err != nil {
|
||||
t.Fatalf("decode artifact-update event: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// collectSSE parses the `data:`-framed JSON-RPC events from an SSE body.
|
||||
func collectSSE(t *testing.T, body string) []streamEvent {
|
||||
t.Helper()
|
||||
var events []streamEvent
|
||||
for _, line := range strings.Split(strings.TrimSpace(body), "\n") {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "data:"))
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var e streamEvent
|
||||
if err := json.Unmarshal([]byte(line), &e); err != nil {
|
||||
t.Fatalf("decode event %q: %v", line, err)
|
||||
}
|
||||
events = append(events, e)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func TestMessageStreamChunksStoreFinalTask(t *testing.T) {
|
||||
d := newDispatcher()
|
||||
body := `{"jsonrpc":"2.0","id":1,"method":"message/stream","params":{"message":{"role":"user","parts":[{"kind":"text","text":"ping"}],"kind":"message"}}}`
|
||||
@@ -449,61 +351,47 @@ func TestMessageStreamChunksStoreFinalTask(t *testing.T) {
|
||||
if ct := rr.Result().Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
|
||||
t.Fatalf("content-type = %q, want text/event-stream", ct)
|
||||
}
|
||||
events := collectSSE(t, rr.Body.String())
|
||||
// Opening Task snapshot + one append artifact-update per chunk + terminal
|
||||
// status-update.
|
||||
if len(events) != 4 {
|
||||
t.Fatalf("events = %d, want 4; body %s", len(events), rr.Body.String())
|
||||
var events []struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
for i, e := range events {
|
||||
if e.Error != nil {
|
||||
t.Fatalf("event %d carried an error field: %+v", i, e.Error)
|
||||
for _, line := range strings.Split(strings.TrimSpace(rr.Body.String()), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
line = strings.TrimPrefix(line, "data: ")
|
||||
var event struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatalf("decode event %q: %v", line, err)
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
if len(events) != 3 {
|
||||
t.Fatalf("events = %d, want 3; body %s", len(events), rr.Body.String())
|
||||
}
|
||||
for i, event := range events {
|
||||
if event.Error != nil {
|
||||
t.Fatalf("event %d error: %+v", i, event.Error)
|
||||
}
|
||||
if event.Result.ID != events[0].Result.ID || event.Result.ContextID != events[0].Result.ContextID {
|
||||
t.Fatalf("event %d changed task identity: %+v vs %+v", i, event.Result, events[0].Result)
|
||||
}
|
||||
}
|
||||
if events[0].kind() != "task" {
|
||||
t.Fatalf("first event kind = %q, want task", events[0].kind())
|
||||
if events[0].Result.Status.State != stateWorking || textOf(events[0].Result.Artifacts[0].Parts) != "po" {
|
||||
t.Fatalf("first event = %+v, want working po", events[0].Result)
|
||||
}
|
||||
opening := events[0].task(t)
|
||||
if opening.Status.State != stateWorking {
|
||||
t.Fatalf("opening task state = %q, want working", opening.Status.State)
|
||||
}
|
||||
taskID := opening.ID
|
||||
|
||||
// The middle events are append artifact-updates carrying the chunk deltas.
|
||||
var text strings.Builder
|
||||
for _, e := range events[1:3] {
|
||||
if e.kind() != "artifact-update" {
|
||||
t.Fatalf("event kind = %q, want artifact-update", e.kind())
|
||||
}
|
||||
au := e.artifactUpdate(t)
|
||||
if !au.Append {
|
||||
t.Fatalf("artifact-update should be append: %+v", au)
|
||||
}
|
||||
if au.TaskID != taskID {
|
||||
t.Fatalf("artifact-update taskId = %q, want %q", au.TaskID, taskID)
|
||||
}
|
||||
text.WriteString(textOf(au.Artifact.Parts))
|
||||
}
|
||||
if text.String() != "pong" {
|
||||
t.Fatalf("accumulated artifact text = %q, want pong", text.String())
|
||||
final := events[len(events)-1].Result
|
||||
if final.Status.State != stateCompleted || textOf(final.Artifacts[0].Parts) != "pong" {
|
||||
t.Fatalf("final event = %+v, want completed pong", final)
|
||||
}
|
||||
|
||||
// The stream closes with a terminal status-update (final:true).
|
||||
last := events[len(events)-1]
|
||||
if last.kind() != "status-update" {
|
||||
t.Fatalf("last event kind = %q, want status-update", last.kind())
|
||||
}
|
||||
su := last.status(t)
|
||||
if !su.Final || su.Status.State != stateCompleted {
|
||||
t.Fatalf("terminal event = %+v, want final completed", su)
|
||||
}
|
||||
if su.TaskID != taskID {
|
||||
t.Fatalf("terminal taskId = %q, want %q", su.TaskID, taskID)
|
||||
}
|
||||
|
||||
got := rpcTaskFromDispatcher(t, d, taskID)
|
||||
if got.ID != taskID || got.Status.State != stateCompleted || textOf(got.Artifacts[0].Parts) != "pong" {
|
||||
t.Fatalf("stored task = %+v, want final completed pong", got)
|
||||
got := rpcTaskFromDispatcher(t, d, final.ID)
|
||||
if got.ID != final.ID || got.Status.State != stateCompleted || textOf(got.Artifacts[0].Parts) != "pong" {
|
||||
t.Fatalf("stored task = %+v, want final", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,31 +432,37 @@ func TestMessageStreamChunksPropagatesCancellationAndClosesStream(t *testing.T)
|
||||
t.Fatal("stream was not closed")
|
||||
}
|
||||
|
||||
events := collectSSE(t, rr.Body.String())
|
||||
// Opening Task snapshot, then a terminal failed status-update.
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("events = %d, want 2; body %s", len(events), rr.Body.String())
|
||||
var events []struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
// A streaming failure must be a failed status-update, never `result` and
|
||||
// `error` set together in one response.
|
||||
for i, e := range events {
|
||||
if e.Error != nil {
|
||||
t.Fatalf("event %d carried an error field (result+error not allowed): %+v", i, e.Error)
|
||||
for _, line := range strings.Split(strings.TrimSpace(rr.Body.String()), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
line = strings.TrimPrefix(line, "data: ")
|
||||
var event struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatalf("decode event %q: %v", line, err)
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
if events[0].kind() != "task" || events[0].task(t).Status.State != stateWorking {
|
||||
t.Fatalf("first event = %s, want working task", string(events[0].Result))
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("events = %d, want 1; body %s", len(events), rr.Body.String())
|
||||
}
|
||||
last := events[1]
|
||||
if last.kind() != "status-update" {
|
||||
t.Fatalf("last event kind = %q, want status-update", last.kind())
|
||||
event := events[0]
|
||||
if event.Error == nil || event.Error.Code != errInternal || event.Error.Message != context.Canceled.Error() {
|
||||
t.Fatalf("error = %+v, want context cancellation", event.Error)
|
||||
}
|
||||
su := last.status(t)
|
||||
if !su.Final || su.Status.State != stateFailed {
|
||||
t.Fatalf("terminal event = %+v, want final failed", su)
|
||||
if event.Result.Status.State != stateFailed || textOf(event.Result.Artifacts[0].Parts) != "error: context canceled" {
|
||||
t.Fatalf("failed task = %+v, want context cancellation artifact", event.Result)
|
||||
}
|
||||
|
||||
got := rpcTaskFromDispatcher(t, d, su.TaskID)
|
||||
got := rpcTaskFromDispatcher(t, d, event.Result.ID)
|
||||
if got.Status.State != stateFailed || textOf(got.Artifacts[0].Parts) != "error: context canceled" {
|
||||
t.Fatalf("stored task = %+v, want failed cancellation", got)
|
||||
}
|
||||
@@ -599,24 +493,33 @@ func TestMessageStreamChunksFallsBackWhenUnsupported(t *testing.T) {
|
||||
if ct := rr.Result().Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
|
||||
t.Fatalf("content-type = %q, want text/event-stream", ct)
|
||||
}
|
||||
events := collectSSE(t, rr.Body.String())
|
||||
// The non-streaming fallback emits a completed Task snapshot then a terminal
|
||||
// status-update.
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("events = %d, want 2; body %s", len(events), rr.Body.String())
|
||||
var events []struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
for i, e := range events {
|
||||
if e.Error != nil {
|
||||
t.Fatalf("fallback event %d error: %+v", i, e.Error)
|
||||
for _, line := range strings.Split(strings.TrimSpace(rr.Body.String()), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
line = strings.TrimPrefix(line, "data: ")
|
||||
var event struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatalf("decode event %q: %v", line, err)
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
task := events[0].task(t)
|
||||
if task.Status.State != stateCompleted || textOf(task.Artifacts[0].Parts) != "pong" {
|
||||
t.Fatalf("fallback task = %+v, want completed pong", task)
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("events = %d, want 1; body %s", len(events), rr.Body.String())
|
||||
}
|
||||
su := events[1].status(t)
|
||||
if !su.Final || su.Status.State != stateCompleted {
|
||||
t.Fatalf("terminal event = %+v, want final completed", su)
|
||||
if events[0].Error != nil {
|
||||
t.Fatalf("fallback event error: %+v", events[0].Error)
|
||||
}
|
||||
if events[0].Result.Status.State != stateCompleted || textOf(events[0].Result.Artifacts[0].Parts) != "pong" {
|
||||
t.Fatalf("fallback task = %+v, want completed pong", events[0].Result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,34 +535,30 @@ func TestMessageStreamFallbackDoesNotCompleteWithEmptyText(t *testing.T) {
|
||||
return nil, fmt.Errorf("%w: test provider", ai.ErrStreamingUnsupported)
|
||||
})
|
||||
|
||||
events := collectSSE(t, rr.Body.String())
|
||||
var task Task
|
||||
var foundTask bool
|
||||
for _, e := range events {
|
||||
if e.Error != nil {
|
||||
t.Fatalf("fallback event error: %+v", e.Error)
|
||||
var event struct {
|
||||
Result Task `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(rr.Body.String()), "\n") {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "data: "))
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if e.kind() == "task" {
|
||||
task = e.task(t)
|
||||
foundTask = true
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatalf("decode event %q: %v", line, err)
|
||||
}
|
||||
}
|
||||
if !foundTask {
|
||||
t.Fatalf("no task event in stream; body %s", rr.Body.String())
|
||||
if event.Error != nil {
|
||||
t.Fatalf("fallback event error: %+v", event.Error)
|
||||
}
|
||||
if task.Status.State != stateFailed {
|
||||
t.Fatalf("fallback state = %q, want failed", task.Status.State)
|
||||
if event.Result.Status.State != stateFailed {
|
||||
t.Fatalf("fallback state = %q, want failed", event.Result.Status.State)
|
||||
}
|
||||
if got := textOf(task.Artifacts[0].Parts); got == "" {
|
||||
t.Fatalf("fallback artifact text is empty: %+v", task.Artifacts)
|
||||
if got := textOf(event.Result.Artifacts[0].Parts); got == "" {
|
||||
t.Fatalf("fallback artifact text is empty: %+v", event.Result.Artifacts)
|
||||
}
|
||||
if got := textOf(task.History[len(task.History)-1].Parts); got == "" {
|
||||
t.Fatalf("fallback history text is empty: %+v", task.History)
|
||||
}
|
||||
// The stream still ends with a terminal marker.
|
||||
last := events[len(events)-1]
|
||||
if last.kind() != "status-update" || !last.status(t).Final {
|
||||
t.Fatalf("stream must end with a final status-update; got %s", string(last.Result))
|
||||
if got := textOf(event.Result.History[len(event.Result.History)-1].Parts); got == "" {
|
||||
t.Fatalf("fallback history text is empty: %+v", event.Result.History)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package a2a
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -53,83 +50,6 @@ func TestAP2PaymentMandateX402RailReference(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAP2GatewayVerifiesInboundPaymentMandate drives a real A2A message/send
|
||||
// carrying a signed x402 payment mandate through the gateway and asserts the
|
||||
// mandate is verified (and the x402 rail carried) into the task a paid path
|
||||
// consults — and that a tampered mandate is surfaced as unverified.
|
||||
func TestAP2GatewayVerifiesInboundPaymentMandate(t *testing.T) {
|
||||
pub, priv := testAP2Key(t)
|
||||
d := newDispatcher()
|
||||
d.ap2Verify = func(s AP2SignedMandate, task Task) AP2Verification {
|
||||
return VerifyAP2ForTask(s, pub, task, nil)
|
||||
}
|
||||
invoke := func(context.Context, string) (string, error) { return "fetched", nil }
|
||||
|
||||
send := func(t *testing.T, mandate AP2SignedMandate) Task {
|
||||
t.Helper()
|
||||
msg := AP2AttachMandate(
|
||||
Message{Role: "user", Kind: "message", MessageID: "m1", Parts: []Part{{Kind: "text", Text: "pay and fetch"}}},
|
||||
mandate,
|
||||
)
|
||||
params, err := json.Marshal(sendParams{Message: msg})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":"message/send","params":%s}`, params)
|
||||
return rpcTaskFromBody(t, d, body, invoke)
|
||||
}
|
||||
|
||||
rail := X402AP2Rail("payreq_777")
|
||||
good, err := SignAP2Mandate(AP2Mandate{ID: "pay-1", Kind: AP2PaymentMandate, Rail: &rail, IssuedAt: time.Unix(1, 0).UTC()}, "k", priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
task := send(t, good)
|
||||
if len(task.AP2Verifications) != 1 || !task.AP2Verifications[0].Verified {
|
||||
t.Fatalf("inbound payment mandate not verified: %+v", task.AP2Verifications)
|
||||
}
|
||||
if task.AP2Verifications[0].Kind != string(AP2PaymentMandate) {
|
||||
t.Errorf("verification kind = %q, want payment", task.AP2Verifications[0].Kind)
|
||||
}
|
||||
if len(task.AP2Mandates) != 1 || task.AP2Mandates[0].Mandate.Rail == nil ||
|
||||
task.AP2Mandates[0].Mandate.Rail.Type != "x402" || task.AP2Mandates[0].Mandate.Rail.Reference != "payreq_777" {
|
||||
t.Fatalf("x402 settlement rail not carried onto task: %+v", task.AP2Mandates)
|
||||
}
|
||||
|
||||
tampered := good
|
||||
tampered.Mandate.Amount = "999.00"
|
||||
bad := send(t, tampered)
|
||||
if len(bad.AP2Verifications) != 1 || bad.AP2Verifications[0].Verified {
|
||||
t.Fatalf("tampered mandate should be unverified: %+v", bad.AP2Verifications)
|
||||
}
|
||||
if !strings.Contains(bad.AP2Verifications[0].Error, "signature") {
|
||||
t.Errorf("tampered verification error = %q, want signature failure", bad.AP2Verifications[0].Error)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAP2CarriedUnverifiedWithoutKey confirms the default (no configured key)
|
||||
// is unchanged: mandates are carried but not verified.
|
||||
func TestAP2CarriedUnverifiedWithoutKey(t *testing.T) {
|
||||
_, priv := testAP2Key(t)
|
||||
d := newDispatcher() // no ap2Verify configured
|
||||
rail := X402AP2Rail("payreq_1")
|
||||
signed, err := SignAP2Mandate(AP2Mandate{ID: "pay-1", Kind: AP2PaymentMandate, Rail: &rail, IssuedAt: time.Unix(1, 0).UTC()}, "k", priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msg := AP2AttachMandate(Message{Role: "user", Kind: "message", MessageID: "m1", Parts: []Part{{Kind: "text", Text: "x"}}}, signed)
|
||||
params, _ := json.Marshal(sendParams{Message: msg})
|
||||
body := fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":"message/send","params":%s}`, params)
|
||||
task := rpcTaskFromBody(t, d, body, func(context.Context, string) (string, error) { return "ok", nil })
|
||||
if len(task.AP2Mandates) != 1 {
|
||||
t.Fatalf("mandate should still be carried: %+v", task.AP2Mandates)
|
||||
}
|
||||
if len(task.AP2Verifications) != 0 {
|
||||
t.Errorf("no verifications without a configured key, got %+v", task.AP2Verifications)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAP2TamperCasesFailDistinctly(t *testing.T) {
|
||||
pub, priv := testAP2Key(t)
|
||||
rail := X402AP2Rail("payreq_123")
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -57,11 +56,9 @@ func TestClientSendAndCard(t *testing.T) {
|
||||
|
||||
func TestClientContinuesTaskAndConfiguresPush(t *testing.T) {
|
||||
card := Card("solo", "http://localhost:4000", "", []string{"task"})
|
||||
// The push receiver below is a loopback test server; authorize it as a
|
||||
// deployment would authorize its trusted push receiver.
|
||||
h := NewAgentHandler(card, func(_ context.Context, text string) (string, error) {
|
||||
return "echo:" + text, nil
|
||||
}, WithPushURLPolicy(func(*url.URL) error { return nil }))
|
||||
})
|
||||
ts := httptest.NewServer(h)
|
||||
defer ts.Close()
|
||||
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
package a2a
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Push-notification callbacks are the one place the A2A gateway makes an
|
||||
// outbound HTTP request to an address chosen by a (possibly untrusted) caller:
|
||||
// tasks/pushNotificationConfig/set records a URL and deliverPush POSTs task
|
||||
// state to it. Without a guard that is a server-side request forgery vector —
|
||||
// a caller can aim the gateway at loopback, link-local (cloud metadata), or
|
||||
// private hosts it would otherwise never reach.
|
||||
//
|
||||
// The default policy allows only http/https callbacks whose host does not
|
||||
// resolve to a loopback, private, link-local, or unspecified address, and the
|
||||
// guarded HTTP client re-checks the *resolved* IP at dial time so a hostname
|
||||
// that passes validation cannot be rebound to an internal address before the
|
||||
// connection is made. Operators who need to reach a trusted in-cluster
|
||||
// receiver set Options.AllowPushURL to take over the policy.
|
||||
|
||||
// pushLookupIP resolves a host to IPs; overridable in tests.
|
||||
var pushLookupIP = net.LookupIP
|
||||
|
||||
// defaultPushURLPolicy is the SSRF-safe policy applied when no AllowPushURL is
|
||||
// configured. It rejects non-http(s) schemes and hosts that resolve to a
|
||||
// loopback, private, link-local, multicast, or unspecified address.
|
||||
func defaultPushURLPolicy(u *url.URL) error {
|
||||
switch u.Scheme {
|
||||
case "http", "https":
|
||||
default:
|
||||
return fmt.Errorf("push callback scheme %q not allowed (want http or https)", u.Scheme)
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return fmt.Errorf("push callback url has no host")
|
||||
}
|
||||
ips, err := resolvePushHost(host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("push callback host %q: %w", host, err)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return fmt.Errorf("push callback host %q did not resolve", host)
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if blockedPushIP(ip) {
|
||||
return fmt.Errorf("push callback host %q resolves to a blocked address %s", host, ip)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolvePushHost(host string) ([]net.IP, error) {
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return []net.IP{ip}, nil
|
||||
}
|
||||
return pushLookupIP(host)
|
||||
}
|
||||
|
||||
// blockedPushIP reports whether ip is one an outbound push callback must not
|
||||
// reach: loopback, private (RFC1918 / ULA), link-local (incl. 169.254.169.254
|
||||
// cloud metadata), multicast, or the unspecified address.
|
||||
func blockedPushIP(ip net.IP) bool {
|
||||
return ip == nil ||
|
||||
ip.IsLoopback() ||
|
||||
ip.IsPrivate() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsInterfaceLocalMulticast() ||
|
||||
ip.IsMulticast() ||
|
||||
ip.IsUnspecified()
|
||||
}
|
||||
|
||||
// pushDialControl runs after DNS resolution, immediately before connect, on the
|
||||
// resolved address — so it blocks a host that passed URL validation but was
|
||||
// rebound to an internal IP (DNS rebinding).
|
||||
func pushDialControl(_, address string, _ syscall.RawConn) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return fmt.Errorf("push callback: cannot parse dial address %q", address)
|
||||
}
|
||||
if blockedPushIP(ip) {
|
||||
return fmt.Errorf("push callback: refusing to connect to blocked address %s", ip)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pushGuardClient is the HTTP client used for default-policy push delivery. Its
|
||||
// dialer refuses connections to blocked addresses at connect time.
|
||||
var pushGuardClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 5 * time.Second,
|
||||
Control: pushDialControl,
|
||||
}).DialContext,
|
||||
},
|
||||
}
|
||||
|
||||
// checkPushURL validates a callback URL against the dispatcher's effective
|
||||
// policy (Options.AllowPushURL, or the default SSRF-safe policy).
|
||||
func (d *dispatcher) checkPushURL(raw string) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid push callback url: %w", err)
|
||||
}
|
||||
policy := d.allowPushURL
|
||||
if policy == nil {
|
||||
policy = defaultPushURLPolicy
|
||||
}
|
||||
return policy(u)
|
||||
}
|
||||
|
||||
// pushClient is the HTTP client deliverPush uses: the guarded client under the
|
||||
// default policy, or the default client when an operator has taken over the
|
||||
// policy via Options.AllowPushURL (they own the trust decision then).
|
||||
func (d *dispatcher) pushClient() *http.Client {
|
||||
if d.guardPushDial {
|
||||
return pushGuardClient
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
package a2a
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultPushURLPolicy(t *testing.T) {
|
||||
// Resolve test hostnames deterministically without real DNS.
|
||||
orig := pushLookupIP
|
||||
pushLookupIP = func(host string) ([]net.IP, error) {
|
||||
switch host {
|
||||
case "internal.example":
|
||||
return []net.IP{net.ParseIP("10.1.2.3")}, nil
|
||||
case "public.example":
|
||||
return []net.IP{net.ParseIP("93.184.216.34")}, nil
|
||||
case "rebind.example":
|
||||
// A host that resolves to both a public and an internal IP must be
|
||||
// rejected — any blocked address is disqualifying.
|
||||
return []net.IP{net.ParseIP("93.184.216.34"), net.ParseIP("127.0.0.1")}, nil
|
||||
}
|
||||
return nil, &net.DNSError{Err: "no such host", Name: host, IsNotFound: true}
|
||||
}
|
||||
defer func() { pushLookupIP = orig }()
|
||||
|
||||
blocked := []string{
|
||||
"http://127.0.0.1/hook", // loopback
|
||||
"http://169.254.169.254/latest/meta", // cloud metadata (link-local)
|
||||
"http://10.0.0.5/hook", // RFC1918
|
||||
"http://[::1]/hook", // IPv6 loopback
|
||||
"http://[fd00::1]/hook", // IPv6 ULA (private)
|
||||
"http://0.0.0.0/hook", // unspecified
|
||||
"http://internal.example/hook", // hostname → private
|
||||
"http://rebind.example/hook", // one internal IP among many
|
||||
"ftp://public.example/hook", // non-http(s) scheme
|
||||
"file:///etc/passwd", // scheme
|
||||
"http:///nohost", // no host
|
||||
}
|
||||
for _, raw := range blocked {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", raw, err)
|
||||
}
|
||||
if err := defaultPushURLPolicy(u); err == nil {
|
||||
t.Errorf("defaultPushURLPolicy(%q) = nil, want blocked", raw)
|
||||
}
|
||||
}
|
||||
|
||||
allowed := []string{
|
||||
"http://93.184.216.34/hook", // public literal IP
|
||||
"https://public.example/hook", // hostname → public
|
||||
}
|
||||
for _, raw := range allowed {
|
||||
u, _ := url.Parse(raw)
|
||||
if err := defaultPushURLPolicy(u); err != nil {
|
||||
t.Errorf("defaultPushURLPolicy(%q) = %v, want allowed", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushDialControlBlocksPrivate(t *testing.T) {
|
||||
blocked := []string{"127.0.0.1:80", "169.254.169.254:80", "10.0.0.1:443", "[::1]:80", "0.0.0.0:80"}
|
||||
for _, addr := range blocked {
|
||||
if err := pushDialControl("tcp", addr, nil); err == nil {
|
||||
t.Errorf("pushDialControl(%q) = nil, want blocked", addr)
|
||||
}
|
||||
}
|
||||
if err := pushDialControl("tcp", "8.8.8.8:443", nil); err != nil {
|
||||
t.Errorf("pushDialControl(public) = %v, want allowed", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetPushConfigRejectsSSRFURL: an untrusted caller cannot register a
|
||||
// callback pointing at an internal address — it is refused and nothing stored.
|
||||
func TestSetPushConfigRejectsSSRFURL(t *testing.T) {
|
||||
d := newDispatcher()
|
||||
d.store(&Task{ID: "t1", ContextID: "c1", Status: TaskStatus{State: stateCompleted}})
|
||||
|
||||
params, _ := json.Marshal(map[string]any{
|
||||
"id": "t1",
|
||||
"pushNotificationConfig": map[string]any{"url": "http://169.254.169.254/latest/meta-data"},
|
||||
})
|
||||
rr := httptest.NewRecorder()
|
||||
d.setPushConfig(rr, rpcRequest{JSONRPC: "2.0", ID: json.RawMessage("1"), Params: params})
|
||||
|
||||
var resp rpcResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Error == nil || resp.Error.Code != errInvalidParams {
|
||||
t.Fatalf("response = %+v, want invalid-params rejection", resp)
|
||||
}
|
||||
d.mu.Lock()
|
||||
_, stored := d.pushConfigs["t1"]
|
||||
d.mu.Unlock()
|
||||
if stored {
|
||||
t.Error("SSRF callback url must not be stored")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeliverPushBlocksInternalByDefault: even if a config for an internal URL
|
||||
// slips into the map, deliverPush must not POST to it under the default policy.
|
||||
func TestDeliverPushBlocksInternalByDefault(t *testing.T) {
|
||||
var hit bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { hit = true }))
|
||||
defer srv.Close() // srv.URL is http://127.0.0.1:PORT — loopback, must be blocked
|
||||
|
||||
d := newDispatcher()
|
||||
task := &Task{ID: "t1", Status: TaskStatus{State: stateCompleted}}
|
||||
d.pushConfigs["t1"] = PushNotificationConfig{URL: srv.URL}
|
||||
|
||||
d.deliverPush("t1", task)
|
||||
if hit {
|
||||
t.Error("deliverPush reached a loopback callback under the default policy")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllowPushURLOverrideDelivers: an operator policy can authorize a trusted
|
||||
// (here loopback) receiver, and delivery then goes through.
|
||||
func TestAllowPushURLOverrideDelivers(t *testing.T) {
|
||||
done := make(chan struct{}, 1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Content-Type") == "application/json" {
|
||||
done <- struct{}{}
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
g := New(Options{AllowPushURL: func(*url.URL) error { return nil }})
|
||||
d := g.disp
|
||||
if d.guardPushDial {
|
||||
t.Fatal("custom AllowPushURL should disable the dial guard")
|
||||
}
|
||||
task := &Task{ID: "t1", Status: TaskStatus{State: stateCompleted}}
|
||||
d.pushConfigs["t1"] = PushNotificationConfig{URL: srv.URL}
|
||||
|
||||
d.deliverPush("t1", task)
|
||||
select {
|
||||
case <-done:
|
||||
default:
|
||||
t.Error("operator-authorized callback was not delivered")
|
||||
}
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
reflectionpb "google.golang.org/grpc/reflection/grpc_reflection_v1alpha"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/reflect/protodesc"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
"google.golang.org/protobuf/reflect/protoregistry"
|
||||
"google.golang.org/protobuf/types/descriptorpb"
|
||||
"google.golang.org/protobuf/types/dynamicpb"
|
||||
)
|
||||
|
||||
// ReflectedGRPCTarget describes an external gRPC server whose reflection
|
||||
// catalog should be exposed as MCP tools. It is intentionally opt-in: teams can
|
||||
// bridge existing reflected gRPC services without changing their servers or
|
||||
// registering them in go-micro.
|
||||
type ReflectedGRPCTarget struct {
|
||||
// Name prefixes generated tools. When empty, Address is sanitized and used.
|
||||
Name string
|
||||
// Address is the host:port of the reflected gRPC server.
|
||||
Address string
|
||||
// DialOptions customize the connection. If none are supplied, an insecure
|
||||
// transport is used for local/dev interoperability.
|
||||
DialOptions []grpc.DialOption
|
||||
// Timeout bounds reflection discovery and individual tool calls.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func (s *Server) discoverReflectedGRPC() error {
|
||||
for _, target := range s.opts.ReflectedGRPCTargets {
|
||||
if strings.TrimSpace(target.Address) == "" {
|
||||
continue
|
||||
}
|
||||
tools, err := s.reflectedGRPCTools(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, tool := range tools {
|
||||
s.tools[tool.Name] = tool
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) reflectedGRPCTools(target ReflectedGRPCTarget) ([]*Tool, error) {
|
||||
timeout := target.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(s.opts.Context, timeout)
|
||||
defer cancel()
|
||||
|
||||
dialOpts := target.DialOptions
|
||||
if len(dialOpts) == 0 {
|
||||
dialOpts = []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
|
||||
}
|
||||
conn, err := grpc.NewClient(target.Address, dialOpts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect reflected grpc target %s: %w", target.Address, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
files, services, err := loadReflectedFiles(ctx, conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reflect grpc target %s: %w", target.Address, err)
|
||||
}
|
||||
|
||||
prefix := target.Name
|
||||
if prefix == "" {
|
||||
prefix = sanitizeToolPart(target.Address)
|
||||
}
|
||||
|
||||
var out []*Tool
|
||||
for _, serviceName := range services {
|
||||
desc, err := files.FindDescriptorByName(protoreflect.FullName(serviceName))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
svc, ok := desc.(protoreflect.ServiceDescriptor)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for i := 0; i < svc.Methods().Len(); i++ {
|
||||
method := svc.Methods().Get(i)
|
||||
if method.IsStreamingClient() || method.IsStreamingServer() {
|
||||
continue
|
||||
}
|
||||
fullMethod := "/" + string(svc.FullName()) + "/" + string(method.Name())
|
||||
toolName := prefix + "." + strings.ReplaceAll(string(svc.FullName()), ".", "_") + "." + string(method.Name())
|
||||
input := method.Input()
|
||||
out = append(out, &Tool{
|
||||
Name: toolName,
|
||||
Description: fmt.Sprintf("Call reflected gRPC method %s on %s", fullMethod, target.Address),
|
||||
InputSchema: protoMessageSchema(input),
|
||||
Handler: reflectedGRPCHandler(target, fullMethod, input, method.Output()),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func loadReflectedFiles(ctx context.Context, conn *grpc.ClientConn) (*protoregistryFiles, []string, error) {
|
||||
client := reflectionpb.NewServerReflectionClient(conn)
|
||||
stream, err := client.ServerReflectionInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := stream.Send(&reflectionpb.ServerReflectionRequest{MessageRequest: &reflectionpb.ServerReflectionRequest_ListServices{ListServices: ""}}); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
resp, err := stream.Recv()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
list := resp.GetListServicesResponse()
|
||||
if list == nil {
|
||||
return nil, nil, fmt.Errorf("reflection list services returned %T", resp.MessageResponse)
|
||||
}
|
||||
|
||||
set := &descriptorpb.FileDescriptorSet{}
|
||||
seen := map[string]bool{}
|
||||
var services []string
|
||||
for _, svc := range list.Service {
|
||||
name := svc.Name
|
||||
if strings.HasPrefix(name, "grpc.reflection.") {
|
||||
continue
|
||||
}
|
||||
services = append(services, name)
|
||||
if err := requestFileContainingSymbol(ctx, client, name, set, seen); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
files, err := newProtoregistryFiles(set)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return files, services, nil
|
||||
}
|
||||
|
||||
func requestFileContainingSymbol(ctx context.Context, client reflectionpb.ServerReflectionClient, symbol string, set *descriptorpb.FileDescriptorSet, seen map[string]bool) error {
|
||||
stream, err := client.ServerReflectionInfo(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := stream.Send(&reflectionpb.ServerReflectionRequest{MessageRequest: &reflectionpb.ServerReflectionRequest_FileContainingSymbol{FileContainingSymbol: symbol}}); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := stream.Recv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fd := resp.GetFileDescriptorResponse()
|
||||
if fd == nil {
|
||||
return fmt.Errorf("reflection lookup for %s returned %T", symbol, resp.MessageResponse)
|
||||
}
|
||||
for _, raw := range fd.FileDescriptorProto {
|
||||
var file descriptorpb.FileDescriptorProto
|
||||
if err := proto.Unmarshal(raw, &file); err != nil {
|
||||
return err
|
||||
}
|
||||
name := file.GetName()
|
||||
if !seen[name] {
|
||||
seen[name] = true
|
||||
set.File = append(set.File, &file)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// protoregistryFiles is a narrow wrapper that keeps imports local to this file.
|
||||
type protoregistryFiles struct{ files *protoregistry.Files }
|
||||
|
||||
func newProtoregistryFiles(set *descriptorpb.FileDescriptorSet) (*protoregistryFiles, error) {
|
||||
files, err := protodesc.NewFiles(set)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &protoregistryFiles{files: files}, nil
|
||||
}
|
||||
|
||||
func (p *protoregistryFiles) FindDescriptorByName(name protoreflect.FullName) (protoreflect.Descriptor, error) {
|
||||
return p.files.FindDescriptorByName(name)
|
||||
}
|
||||
|
||||
func reflectedGRPCHandler(target ReflectedGRPCTarget, fullMethod string, input, output protoreflect.MessageDescriptor) func(map[string]interface{}) (interface{}, error) {
|
||||
return func(args map[string]interface{}) (interface{}, error) {
|
||||
timeout := target.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
dialOpts := target.DialOptions
|
||||
if len(dialOpts) == 0 {
|
||||
dialOpts = []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
|
||||
}
|
||||
conn, err := grpc.NewClient(target.Address, dialOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
req := dynamicpb.NewMessage(input)
|
||||
raw, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := protojson.Unmarshal(raw, req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rsp := dynamicpb.NewMessage(output)
|
||||
if err := conn.Invoke(ctx, fullMethod, req, rsp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := protojson.MarshalOptions{UseProtoNames: true, EmitUnpopulated: true}.Marshal(rsp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out interface{}
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
func protoMessageSchema(msg protoreflect.MessageDescriptor) map[string]interface{} {
|
||||
schema := map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}
|
||||
props := schema["properties"].(map[string]interface{})
|
||||
fields := msg.Fields()
|
||||
for i := 0; i < fields.Len(); i++ {
|
||||
field := fields.Get(i)
|
||||
props[field.JSONName()] = protoFieldSchema(field)
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
func protoFieldSchema(field protoreflect.FieldDescriptor) map[string]interface{} {
|
||||
schema := map[string]interface{}{"type": protoJSONType(field)}
|
||||
if field.IsList() {
|
||||
schema["items"] = map[string]interface{}{"type": protoJSONType(field)}
|
||||
}
|
||||
if field.Kind() == protoreflect.MessageKind || field.Kind() == protoreflect.GroupKind {
|
||||
schema = protoMessageSchema(field.Message())
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
func protoJSONType(field protoreflect.FieldDescriptor) string {
|
||||
if field.IsList() {
|
||||
return "array"
|
||||
}
|
||||
switch field.Kind() {
|
||||
case protoreflect.BoolKind:
|
||||
return "boolean"
|
||||
case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind,
|
||||
protoreflect.Uint32Kind, protoreflect.Fixed32Kind, protoreflect.Int64Kind,
|
||||
protoreflect.Sint64Kind, protoreflect.Sfixed64Kind, protoreflect.Uint64Kind,
|
||||
protoreflect.Fixed64Kind:
|
||||
return "integer"
|
||||
case protoreflect.FloatKind, protoreflect.DoubleKind:
|
||||
return "number"
|
||||
case protoreflect.MessageKind, protoreflect.GroupKind:
|
||||
return "object"
|
||||
default:
|
||||
return "string"
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeToolPart(s string) string {
|
||||
r := strings.NewReplacer(":", "_", "/", "_", ".", "_", "-", "_")
|
||||
return r.Replace(s)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
helloworld "google.golang.org/grpc/examples/helloworld/helloworld"
|
||||
"google.golang.org/grpc/reflection"
|
||||
)
|
||||
|
||||
type reflectedGreeter struct {
|
||||
helloworld.UnimplementedGreeterServer
|
||||
}
|
||||
|
||||
func (reflectedGreeter) SayHello(_ context.Context, req *helloworld.HelloRequest) (*helloworld.HelloReply, error) {
|
||||
return &helloworld.HelloReply{Message: "hello " + req.Name}, nil
|
||||
}
|
||||
|
||||
func TestReflectedGRPCTargetDiscoversAndCallsUnaryTool(t *testing.T) {
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grpcServer := grpc.NewServer()
|
||||
helloworld.RegisterGreeterServer(grpcServer, reflectedGreeter{})
|
||||
reflection.Register(grpcServer)
|
||||
go grpcServer.Serve(lis)
|
||||
defer grpcServer.Stop()
|
||||
|
||||
s := newTestServer(Options{Context: context.Background()})
|
||||
tools, err := s.reflectedGRPCTools(ReflectedGRPCTarget{
|
||||
Name: "demo",
|
||||
Address: lis.Addr().String(),
|
||||
Timeout: 3 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("discover reflected tools: %v", err)
|
||||
}
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("tools len = %d, want 1", len(tools))
|
||||
}
|
||||
tool := tools[0]
|
||||
if tool.Name != "demo.helloworld_Greeter.SayHello" {
|
||||
t.Fatalf("tool name = %q", tool.Name)
|
||||
}
|
||||
props := tool.InputSchema["properties"].(map[string]interface{})
|
||||
if _, ok := props["name"]; !ok {
|
||||
t.Fatalf("input schema missing name: %#v", tool.InputSchema)
|
||||
}
|
||||
|
||||
out, err := tool.Handler(map[string]interface{}{"name": "Ada"})
|
||||
if err != nil {
|
||||
t.Fatalf("call reflected tool: %v", err)
|
||||
}
|
||||
got := out.(map[string]interface{})["message"]
|
||||
if got != "hello Ada" {
|
||||
t.Fatalf("message = %v, want hello Ada", got)
|
||||
}
|
||||
}
|
||||
@@ -157,11 +157,6 @@ type Options struct {
|
||||
// (the /mcp/call endpoint). Listing tools and health stay free.
|
||||
// Opt-in: leave nil to disable payments.
|
||||
Payment *x402.Config
|
||||
|
||||
// ReflectedGRPCTargets exposes unary methods from external gRPC servers
|
||||
// that support server reflection as MCP tools. This bridges existing gRPC
|
||||
// services into the agent tool catalog without requiring go-micro handlers.
|
||||
ReflectedGRPCTargets []ReflectedGRPCTarget
|
||||
}
|
||||
|
||||
// Server represents a running MCP gateway
|
||||
@@ -291,10 +286,6 @@ func (s *Server) discoverServices() error {
|
||||
s.toolsMu.Lock()
|
||||
defer s.toolsMu.Unlock()
|
||||
|
||||
if err := s.discoverReflectedGRPC(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, svc := range services {
|
||||
// Get full service details
|
||||
fullSvcs, err := s.opts.Registry.GetService(svc.Name)
|
||||
|
||||
+19
-5
@@ -294,9 +294,7 @@ func (t *StdioTransport) handleToolsCall(req *JSONRPCRequest) {
|
||||
AccountID: accountID, ScopesRequired: tool.Scopes,
|
||||
Allowed: true, Duration: time.Since(start), Error: err.Error(),
|
||||
})
|
||||
// A tool-execution failure is reported as an isError result, not a
|
||||
// JSON-RPC protocol error (per the MCP spec), so the agent can read it.
|
||||
t.sendResponse(req.ID, mcpToolError(traceID, "tool call failed: "+err.Error()))
|
||||
t.sendError(req.ID, InternalError, "RPC call failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -313,8 +311,24 @@ func (t *StdioTransport) handleToolsCall(req *JSONRPCRequest) {
|
||||
Allowed: true, Duration: time.Since(start),
|
||||
})
|
||||
|
||||
// The downstream response is JSON — return it as JSON text, not %v.
|
||||
t.sendResponse(req.ID, mcpToolResult(traceID, rsp.Data))
|
||||
// Parse response
|
||||
var result interface{}
|
||||
if err := json.Unmarshal(rsp.Data, &result); err != nil {
|
||||
// If unmarshal fails, return raw data
|
||||
result = map[string]interface{}{
|
||||
"data": string(rsp.Data),
|
||||
}
|
||||
}
|
||||
|
||||
t.sendResponse(req.ID, map[string]interface{}{
|
||||
"content": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "text",
|
||||
"text": fmt.Sprintf("%v", result),
|
||||
},
|
||||
},
|
||||
"trace_id": traceID,
|
||||
})
|
||||
}
|
||||
|
||||
// sendResponse sends a JSON-RPC response
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/client"
|
||||
)
|
||||
|
||||
// fakeCallClient overrides Call to return canned data or an error; NewRequest
|
||||
// and the rest are promoted from the embedded real client.
|
||||
type fakeCallClient struct {
|
||||
client.Client
|
||||
data []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeCallClient) Call(ctx context.Context, req client.Request, rsp interface{}, opts ...client.CallOption) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
if r, ok := rsp.(*struct{ Data []byte }); ok {
|
||||
r.Data = f.data
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isToolError reports whether an MCP tools/call result carries isError:true.
|
||||
func isToolError(result interface{}) bool {
|
||||
m, ok := result.(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
b, _ := m["isError"].(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
// toolResultText extracts the first text content of an MCP tools/call result.
|
||||
func toolResultText(t *testing.T, result interface{}) string {
|
||||
t.Helper()
|
||||
m, ok := result.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("result is not a map: %#v", result)
|
||||
}
|
||||
content, ok := m["content"].([]interface{})
|
||||
if !ok || len(content) == 0 {
|
||||
t.Fatalf("result has no content: %#v", result)
|
||||
}
|
||||
first, _ := content[0].(map[string]interface{})
|
||||
text, _ := first["text"].(string)
|
||||
return text
|
||||
}
|
||||
|
||||
// driveStdio sends one JSON-RPC request through a StdioTransport and returns the
|
||||
// decoded response, capturing the transport's stdout into a buffer.
|
||||
func driveStdio(t *testing.T, s *Server, method string, id interface{}, params interface{}) JSONRPCResponse {
|
||||
t.Helper()
|
||||
tr := NewStdioTransport(s)
|
||||
var out bytes.Buffer
|
||||
tr.writer = bufio.NewWriter(&out)
|
||||
raw, _ := json.Marshal(params)
|
||||
tr.handleRequest(&JSONRPCRequest{JSONRPC: "2.0", ID: id, Method: method, Params: raw})
|
||||
var resp JSONRPCResponse
|
||||
if err := json.Unmarshal(bytes.TrimSpace(out.Bytes()), &resp); err != nil {
|
||||
t.Fatalf("decode stdio response: %v (raw=%q)", err, out.String())
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// The stdio transport is the path an external MCP host (Claude Desktop) uses.
|
||||
// It must return tool output as JSON text, not fmt.Sprintf("%v", ...) which
|
||||
// yields Go map-syntax and is unparseable by a real client.
|
||||
func TestStdio_ToolsCall_ReturnsJSONNotGoSyntax(t *testing.T) {
|
||||
s := newTestServer(Options{})
|
||||
s.opts.Client = &fakeCallClient{Client: client.DefaultClient, data: []byte(`{"id":1,"name":"bob"}`)}
|
||||
s.tools["svc.Echo"] = &Tool{Name: "svc.Echo", Service: "svc", Endpoint: "Echo"}
|
||||
|
||||
resp := driveStdio(t, s, "tools/call", 1, map[string]interface{}{
|
||||
"name": "svc.Echo",
|
||||
"arguments": map[string]interface{}{"msg": "hi"},
|
||||
})
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("unexpected protocol error: %+v", resp.Error)
|
||||
}
|
||||
text := toolResultText(t, resp.Result)
|
||||
// The bug returned Go map-syntax ("map[id:1 name:bob]"), which fails to parse.
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(text), &got); err != nil {
|
||||
t.Fatalf("tool result text is not JSON (the %%v bug): %q", text)
|
||||
}
|
||||
if got["name"] != "bob" {
|
||||
t.Errorf("result = %v, want name=bob", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A tool-execution failure must be an MCP isError result, not a JSON-RPC
|
||||
// protocol error, so the agent can read the failure.
|
||||
func TestStdio_ToolsCall_FailureIsIsErrorResult(t *testing.T) {
|
||||
s := newTestServer(Options{})
|
||||
s.opts.Client = &fakeCallClient{Client: client.DefaultClient, err: errors.New("backend down")}
|
||||
s.tools["svc.Echo"] = &Tool{Name: "svc.Echo", Service: "svc", Endpoint: "Echo"}
|
||||
|
||||
resp := driveStdio(t, s, "tools/call", 1, map[string]interface{}{
|
||||
"name": "svc.Echo",
|
||||
"arguments": map[string]interface{}{},
|
||||
})
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("tool failure returned a protocol error, want isError result: %+v", resp.Error)
|
||||
}
|
||||
if !isToolError(resp.Result) {
|
||||
t.Fatalf("expected isError result, got %+v", resp.Result)
|
||||
}
|
||||
if text := toolResultText(t, resp.Result); text == "" {
|
||||
t.Error("isError result should carry the error text")
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package mcp
|
||||
|
||||
// MCP tools/call result shaping, shared by the stdio and websocket JSON-RPC
|
||||
// transports. Kept in one place so both transports produce spec-shaped results.
|
||||
|
||||
// mcpToolResult builds a successful MCP tools/call result. The downstream RPC
|
||||
// response body (data) is JSON, so it is returned as JSON text — NOT
|
||||
// fmt.Sprintf("%v", ...) of a decoded value, which produces Go map-syntax
|
||||
// (map[id:1 name:bob]) instead of JSON and is what an external MCP client
|
||||
// (e.g. Claude Desktop over stdio) would otherwise receive.
|
||||
func mcpToolResult(traceID string, data []byte) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"content": []interface{}{
|
||||
map[string]interface{}{"type": "text", "text": string(data)},
|
||||
},
|
||||
"trace_id": traceID,
|
||||
}
|
||||
}
|
||||
|
||||
// mcpToolError builds an MCP tools/call result for a tool-EXECUTION failure.
|
||||
// Per the MCP spec a tool that fails returns a normal result with isError:true
|
||||
// (the error as text content), NOT a JSON-RPC protocol error — that way the
|
||||
// agent can read the failure instead of seeing a transport-level error.
|
||||
func mcpToolError(traceID, msg string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"content": []interface{}{
|
||||
map[string]interface{}{"type": "text", "text": msg},
|
||||
},
|
||||
"isError": true,
|
||||
"trace_id": traceID,
|
||||
}
|
||||
}
|
||||
@@ -275,8 +275,7 @@ func (wc *wsConn) handleToolsCall(req *JSONRPCRequest) {
|
||||
AccountID: accountID, ScopesRequired: tool.Scopes,
|
||||
Allowed: true, Duration: time.Since(start), Error: err.Error(),
|
||||
})
|
||||
// Tool-execution failure → isError result (MCP spec), not a protocol error.
|
||||
wc.sendResponse(req.ID, mcpToolError(traceID, "tool call failed: "+err.Error()))
|
||||
wc.sendError(req.ID, InternalError, "RPC call failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -292,8 +291,23 @@ func (wc *wsConn) handleToolsCall(req *JSONRPCRequest) {
|
||||
Allowed: true, Duration: time.Since(start),
|
||||
})
|
||||
|
||||
// The downstream response is JSON — return it as JSON text, not %v.
|
||||
wc.sendResponse(req.ID, mcpToolResult(traceID, rsp.Data))
|
||||
// Parse response
|
||||
var result interface{}
|
||||
if err := json.Unmarshal(rsp.Data, &result); err != nil {
|
||||
result = map[string]interface{}{
|
||||
"data": string(rsp.Data),
|
||||
}
|
||||
}
|
||||
|
||||
wc.sendResponse(req.ID, map[string]interface{}{
|
||||
"content": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "text",
|
||||
"text": fmt.Sprintf("%v", result),
|
||||
},
|
||||
},
|
||||
"trace_id": traceID,
|
||||
})
|
||||
}
|
||||
|
||||
// sendResponse sends a JSON-RPC success response.
|
||||
|
||||
@@ -114,13 +114,12 @@ func TestWebSocket_ToolsCall_NoAuth(t *testing.T) {
|
||||
"arguments": map[string]interface{}{"msg": "hi"},
|
||||
})
|
||||
|
||||
// No auth required → the tool runs; the RPC fails (no backend), which the
|
||||
// MCP spec surfaces as an isError result, not a JSON-RPC protocol error.
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("expected no protocol error, got %+v", resp.Error)
|
||||
// RPC will fail (no backend), but auth should pass (no auth configured)
|
||||
if resp.Error == nil {
|
||||
t.Fatal("expected RPC error (no backend)")
|
||||
}
|
||||
if !isToolError(resp.Result) {
|
||||
t.Fatalf("expected isError tool result, got %+v", resp.Result)
|
||||
if resp.Error.Code != InternalError {
|
||||
t.Errorf("error code = %d, want %d", resp.Error.Code, InternalError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,13 +168,12 @@ func TestWebSocket_ToolsCall_AuthRequired(t *testing.T) {
|
||||
"arguments": map[string]interface{}{},
|
||||
"_token": "valid-token",
|
||||
})
|
||||
// Auth passes → the tool runs; RPC fails (no backend) → isError result,
|
||||
// not a JSON-RPC protocol error (which would mean auth failed).
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("expected no protocol error (auth passed), got %+v", resp.Error)
|
||||
// Auth passes, RPC fails (no backend)
|
||||
if resp.Error == nil {
|
||||
t.Fatal("expected RPC error")
|
||||
}
|
||||
if !isToolError(resp.Result) {
|
||||
t.Fatalf("expected isError tool result, got %+v", resp.Result)
|
||||
if resp.Error.Code != InternalError {
|
||||
t.Errorf("error code = %d, want %d (RPC fail, not auth fail)", resp.Error.Code, InternalError)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -187,13 +185,12 @@ func TestWebSocket_ToolsCall_AuthRequired(t *testing.T) {
|
||||
"name": "svc.Do",
|
||||
"arguments": map[string]interface{}{},
|
||||
})
|
||||
// Auth passes via connection-level header → tool runs; RPC fails (no
|
||||
// backend) → isError result, not a JSON-RPC protocol error.
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("expected no protocol error (auth passed), got %+v", resp.Error)
|
||||
// Auth passes via connection-level header, RPC fails (no backend)
|
||||
if resp.Error == nil {
|
||||
t.Fatal("expected RPC error")
|
||||
}
|
||||
if !isToolError(resp.Result) {
|
||||
t.Fatalf("expected isError tool result, got %+v", resp.Result)
|
||||
if resp.Error.Code != InternalError {
|
||||
t.Errorf("error code = %d, want %d (RPC fail, not auth fail)", resp.Error.Code, InternalError)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -166,9 +166,7 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Spec-shaped stream: at least one artifact-update carrying the reassembled
|
||||
// answer, terminating in a completed status-update with final:true.
|
||||
if summary.ArtifactEvents == 0 || summary.State != "completed" || !summary.Final || !strings.Contains(summary.FinalText, "a2a-stream-ok") {
|
||||
if summary.WorkingEvents == 0 || summary.State != "completed" || !strings.Contains(summary.FinalText, "a2a-stream-ok") {
|
||||
fmt.Fprintf(os.Stderr, "unexpected stream summary: %+v\npayload:\n%s", summary, summary.Payload)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -176,16 +174,14 @@ func main() {
|
||||
fmt.Fprintf(os.Stderr, "tool=%v runInfo=%v\n", sawTool, sawRunInfo)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("\n\033[32m✓ A2A message/stream emitted spec-shaped artifact/status updates and preserved tool/run metadata\033[0m")
|
||||
fmt.Println("\n\033[32m✓ A2A message/stream emitted incremental task updates and preserved tool/run metadata\033[0m")
|
||||
}
|
||||
|
||||
type streamSummary struct {
|
||||
Payload string
|
||||
State string
|
||||
FinalText string
|
||||
Final bool
|
||||
ArtifactEvents int
|
||||
WorkingEvents int
|
||||
Payload string
|
||||
State string
|
||||
FinalText string
|
||||
WorkingEvents int
|
||||
}
|
||||
|
||||
func readSSESummary(r io.Reader) (streamSummary, error) {
|
||||
@@ -201,23 +197,14 @@ func readSSESummary(r io.Reader) (streamSummary, error) {
|
||||
}
|
||||
var envelope struct {
|
||||
Result struct {
|
||||
Kind string `json:"kind"`
|
||||
Final bool `json:"final"`
|
||||
Status struct {
|
||||
State string `json:"state"`
|
||||
} `json:"status"`
|
||||
// Task snapshots carry artifacts (plural)...
|
||||
Artifacts []struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"artifacts"`
|
||||
// ...artifact-update events carry a single artifact.
|
||||
Artifact struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"artifact"`
|
||||
} `json:"result"`
|
||||
Error any `json:"error"`
|
||||
}
|
||||
@@ -229,34 +216,15 @@ func readSSESummary(r io.Reader) (streamSummary, error) {
|
||||
}
|
||||
seen = true
|
||||
summary.Payload += data + "\n"
|
||||
switch envelope.Result.Kind {
|
||||
case "artifact-update":
|
||||
// Incremental deltas: reassemble the streamed answer.
|
||||
summary.ArtifactEvents++
|
||||
for _, part := range envelope.Result.Artifact.Parts {
|
||||
summary.FinalText += part.Text
|
||||
}
|
||||
case "status-update":
|
||||
if envelope.Result.Status.State != "" {
|
||||
summary.State = envelope.Result.Status.State
|
||||
}
|
||||
if envelope.Result.Final {
|
||||
summary.Final = true
|
||||
}
|
||||
default: // "task" snapshot
|
||||
if envelope.Result.Status.State == "working" {
|
||||
summary.WorkingEvents++
|
||||
}
|
||||
if envelope.Result.Status.State != "" {
|
||||
summary.State = envelope.Result.Status.State
|
||||
}
|
||||
// The non-streaming path carries the full text in the snapshot.
|
||||
for _, artifact := range envelope.Result.Artifacts {
|
||||
for _, part := range artifact.Parts {
|
||||
if part.Text != "" {
|
||||
summary.FinalText = part.Text
|
||||
}
|
||||
}
|
||||
if envelope.Result.Status.State == "working" {
|
||||
summary.WorkingEvents++
|
||||
}
|
||||
if envelope.Result.Status.State != "" {
|
||||
summary.State = envelope.Result.Status.State
|
||||
}
|
||||
for _, artifact := range envelope.Result.Artifacts {
|
||||
for _, part := range artifact.Parts {
|
||||
summary.FinalText = part.Text
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
// Package network is a process-local registry of server dispatchers — the
|
||||
// neutral seam an in-process client fast-path uses to reach a server running in
|
||||
// the same process without going over the network transport.
|
||||
//
|
||||
// It lives in internal/ and speaks only in transport.Message so neither the
|
||||
// client nor the server package has to import the other: a running server
|
||||
// registers a Handler under its service name; an opted-in client looks one up
|
||||
// and dispatches directly, skipping dial, codec-over-socket, and the transport
|
||||
// pump. Nothing here runs unless a server registers and a client opts in.
|
||||
package network
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"go-micro.dev/v6/transport"
|
||||
)
|
||||
|
||||
// Handler dispatches one request against a process-local server's handler
|
||||
// table and returns the reply. req and the returned message carry the same
|
||||
// codec-encoded body + headers the transport would have carried.
|
||||
type Handler func(ctx context.Context, req *transport.Message) (*transport.Message, error)
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
reg = map[string]Handler{}
|
||||
)
|
||||
|
||||
// Register makes service reachable in-process via h. A server calls this when
|
||||
// it starts; calling again replaces the handler.
|
||||
func Register(service string, h Handler) {
|
||||
mu.Lock()
|
||||
reg[service] = h
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// Deregister removes service's in-process handler. A server calls this when it
|
||||
// stops, so a later in-process call falls back to the network path.
|
||||
func Deregister(service string) {
|
||||
mu.Lock()
|
||||
delete(reg, service)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// Lookup returns the in-process handler for service, if one is registered.
|
||||
func Lookup(service string) (Handler, bool) {
|
||||
mu.RLock()
|
||||
h, ok := reg[service]
|
||||
mu.RUnlock()
|
||||
return h, ok
|
||||
}
|
||||
@@ -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 | Yes | No |
|
||||
| `gemini` | Yes | No | No | No | No |
|
||||
| `groq` | Yes | No | No | Yes | Yes |
|
||||
| `minimax` | Yes | No | No | Yes | Yes |
|
||||
| `mistral` | Yes | No | No | Yes | Yes |
|
||||
|
||||
@@ -150,71 +150,29 @@ See [Native gRPC Compatibility](grpc-compatibility.md) for a complete guide.
|
||||
|
||||
## vs Dapr
|
||||
|
||||
[Dapr](https://dapr.io/) is a distributed application runtime. Its building
|
||||
blocks cover service invocation, state, pub/sub, bindings, secrets,
|
||||
configuration, distributed locks, actors, jobs, and workflow, usually accessed
|
||||
through a sidecar from many languages. [Dapr Agents](https://docs.dapr.io/developing-ai/dapr-agents/)
|
||||
adds an agent framework on top of those runtime capabilities.
|
||||
### Dapr Approach
|
||||
- Multi-language via sidecar
|
||||
- Rich building blocks (state, pub/sub, bindings)
|
||||
- Cloud-native focused
|
||||
- Requires running sidecar process
|
||||
|
||||
Go Micro overlaps with Dapr on distributed-systems primitives, but the product
|
||||
shape is different: Go Micro is a Go framework where services, agents, tools,
|
||||
and flows are built from the same runtime. A service endpoint can become an
|
||||
AI-callable tool, and an agent is itself a registered service with memory,
|
||||
guardrails, planning, delegation, MCP, and A2A around it.
|
||||
### Go Micro Approach
|
||||
- Go library, no sidecar
|
||||
- Direct service-to-service calls
|
||||
- Simpler deployment
|
||||
- Lower latency (no extra hop)
|
||||
|
||||
### Decision table
|
||||
### When to Choose Dapr
|
||||
- You have polyglot services (Node, Python, Java, etc)
|
||||
- You want portable abstractions across clouds
|
||||
- You're fully on Kubernetes
|
||||
- You need state management abstractions
|
||||
|
||||
| Need | Prefer Go Micro | Prefer Dapr | Use both |
|
||||
|---|---|---|---|
|
||||
| **Primary language** | Your core runtime is Go and you want library-native APIs | You run a polyglot estate and want one sidecar API across languages | Go services use Go Micro while non-Go services expose Dapr APIs |
|
||||
| **Agent model** | Agents should be ordinary services: registered, discoverable, callable by RPC, MCP, and A2A | Agents are primarily Python applications using Dapr Agents | Dapr-hosted agents call Go Micro MCP tools, or Go Micro agents call Dapr-backed services |
|
||||
| **Tools** | Existing service endpoints should become tools with minimal extra code | Tools are modeled through Dapr components, bindings, or agent framework code | Use Dapr components behind Go Micro services that expose a stable tool surface |
|
||||
| **Workflows** | Deterministic steps should live beside Go services and agents in the same codebase | You want Dapr Workflow's sidecar-backed orchestration model across languages | Let Dapr own cross-language workflows and let Go Micro own Go-native agent/tool execution |
|
||||
| **State and pub/sub** | You want Go interfaces and pluggable packages directly in-process | You want component YAML and sidecar portability across backing services | Put portable infrastructure behind Dapr and domain/tool logic in Go Micro |
|
||||
| **Deployment** | You want a simple Go binary/runtime first, with Kubernetes support as an explicit deployment target | You are already standardized on Dapr sidecars in Kubernetes | Run Go Micro services in clusters that already have Dapr for shared infrastructure |
|
||||
| **Interop** | MCP and A2A are first-class requirements for exposing services and agents | Dapr's app APIs and agent framework are the integration boundary | Bridge through MCP/A2A at the agent edge and Dapr APIs at the infrastructure edge |
|
||||
|
||||
### When to choose Dapr
|
||||
|
||||
- You need a **polyglot** runtime contract for Node, Python, Java, .NET, Go, and
|
||||
other services.
|
||||
- Your platform team already operates sidecars and component configuration across
|
||||
Kubernetes clusters.
|
||||
- You want Dapr's standard building blocks for state, pub/sub, bindings, secrets,
|
||||
actors, jobs, and workflow more than you want a Go-native service framework.
|
||||
- You are adopting Dapr Agents and want to stay in its Python-first agent stack.
|
||||
|
||||
### When to choose Go Micro
|
||||
|
||||
- You are building mostly in Go and want the agent harness to be the same runtime
|
||||
as your services.
|
||||
- You want service methods and their comments/examples to become AI-callable tools
|
||||
without maintaining a separate tool layer.
|
||||
- You want agents to be deployed, discovered, called, load-balanced, and inspected
|
||||
like ordinary services.
|
||||
- You need MCP and A2A at the agent/service boundary, not only an internal
|
||||
application API.
|
||||
- You prefer library-native composition and direct Go interfaces over sidecar
|
||||
component wiring.
|
||||
|
||||
### Where Go Micro still needs to prove itself
|
||||
|
||||
Dapr has a mature platform narrative and broad deployment footprint. Go Micro's
|
||||
agent-harness story is sharper for Go teams, but production adoption depends on
|
||||
keeping the no-secret getting-started path green, documenting durability
|
||||
semantics clearly, proving MCP/A2A conformance with external clients, and making
|
||||
Kubernetes deployment first-class.
|
||||
|
||||
### Practical migration path
|
||||
|
||||
1. Start with one Go Micro service that wraps a real domain capability.
|
||||
2. Add doc comments and examples so the endpoint is useful as an agent tool.
|
||||
3. Expose it through MCP for external agents or through A2A if the capability is
|
||||
itself an agent.
|
||||
4. If your platform already uses Dapr, keep Dapr components behind the service
|
||||
boundary and let Go Micro present the agent/tool contract.
|
||||
5. Move deterministic multi-step work into flows only after the service/tool
|
||||
boundary is stable.
|
||||
### When to Choose Go Micro
|
||||
- You're building Go services
|
||||
- You want lower latency
|
||||
- You prefer libraries over sidecars
|
||||
- You want simpler deployment (no sidecar management)
|
||||
|
||||
## vs Agent Frameworks (Google ADK)
|
||||
|
||||
|
||||
@@ -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 | ✅ Verified when configured | ⚠️ Unverified |
|
||||
| `gemini` | ✅ Verified when configured | — Unsupported | — Unsupported | ⚠️ Unverified | ⚠️ 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 |
|
||||
|
||||
@@ -64,7 +64,7 @@ Otherwise continue to read the docs for more information about the framework.
|
||||
|
||||
## Advanced
|
||||
|
||||
- [Framework Comparison](guides/comparison.html) - Including Go Micro vs Dapr for agents, services, and workflows
|
||||
- [Framework Comparison](guides/comparison.html)
|
||||
- [Architecture Decisions](architecture/)
|
||||
- [Real-World Examples](examples/realworld/)
|
||||
- [Migration Guides](guides/migration/)
|
||||
|
||||
@@ -12,7 +12,6 @@ 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"
|
||||
)
|
||||
|
||||
@@ -131,13 +130,6 @@ 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) }
|
||||
|
||||
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"go-micro.dev/v6/internal/network"
|
||||
"go-micro.dev/v6/transport"
|
||||
"go-micro.dev/v6/transport/headers"
|
||||
)
|
||||
|
||||
// local.go gives a same-process caller a way to reach this server's handlers
|
||||
// without the network transport. A running server registers a dispatcher in
|
||||
// internal/network keyed by its name; an opted-in client looks it up and
|
||||
// calls localDispatch, which serves the request synchronously through the same
|
||||
// router (so handler wrappers, codecs, and error mapping are identical) over an
|
||||
// in-memory socket — skipping dial, the transport pump, and the codec-over-pipe
|
||||
// double serialization. Unary only; streaming and pub/sub keep the normal path.
|
||||
|
||||
// localSocket is a transport.Socket that carries exactly one request in and
|
||||
// captures exactly one reply — no network, no pipe, no gob. Recv delivers the
|
||||
// request message once (the RPC codec reads it on the first ReadHeader), then
|
||||
// reports EOF; Send captures the encoded reply.
|
||||
type localSocket struct {
|
||||
req *transport.Message
|
||||
recvd bool
|
||||
reply *transport.Message
|
||||
}
|
||||
|
||||
func (s *localSocket) Recv(m *transport.Message) error {
|
||||
if s.recvd || s.req == nil {
|
||||
return io.EOF
|
||||
}
|
||||
s.recvd = true
|
||||
m.Header = s.req.Header
|
||||
m.Body = s.req.Body
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *localSocket) Send(m *transport.Message) error {
|
||||
cp := &transport.Message{Header: make(map[string]string, len(m.Header))}
|
||||
for k, v := range m.Header {
|
||||
cp.Header[k] = v
|
||||
}
|
||||
if len(m.Body) > 0 {
|
||||
cp.Body = append([]byte(nil), m.Body...)
|
||||
}
|
||||
s.reply = cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *localSocket) Close() error { return nil }
|
||||
func (s *localSocket) Local() string { return "local" }
|
||||
func (s *localSocket) Remote() string { return "local" }
|
||||
|
||||
// localDispatch serves req against this server's router in-process and returns
|
||||
// the reply. It mirrors the request/response construction ServeConn does for a
|
||||
// networked request, so the served path is identical apart from the transport.
|
||||
func (s *rpcServer) localDispatch(ctx context.Context, req *transport.Message) (*transport.Message, error) {
|
||||
contentType := req.Header["Content-Type"]
|
||||
if contentType == "" {
|
||||
contentType = DefaultContentType
|
||||
req.Header["Content-Type"] = contentType
|
||||
}
|
||||
|
||||
cf := setupProtocol(req)
|
||||
if cf == nil {
|
||||
var err error
|
||||
if cf, err = s.newCodec(contentType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
sock := &localSocket{req: req}
|
||||
rcodec := newRPCCodec(req, sock, cf)
|
||||
|
||||
request := rpcRequest{
|
||||
service: getHeader(headers.Request, req.Header),
|
||||
method: getHeader(headers.Method, req.Header),
|
||||
endpoint: getHeader(headers.Endpoint, req.Header),
|
||||
contentType: contentType,
|
||||
codec: rcodec,
|
||||
header: req.Header,
|
||||
body: req.Body,
|
||||
socket: sock,
|
||||
}
|
||||
response := rpcResponse{
|
||||
header: make(map[string]string),
|
||||
socket: sock,
|
||||
codec: rcodec,
|
||||
}
|
||||
|
||||
if err := s.getRouter().ServeRequest(ctx, &request, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sock.reply == nil {
|
||||
// A handler that wrote no body still completed successfully.
|
||||
return &transport.Message{Header: map[string]string{}}, nil
|
||||
}
|
||||
return sock.reply, nil
|
||||
}
|
||||
|
||||
// registerLocal makes this server reachable in-process under its name; called
|
||||
// on Start. deregisterLocal removes it on Stop.
|
||||
func (s *rpcServer) registerLocal() {
|
||||
name := s.Options().Name
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
network.Register(name, s.localDispatch)
|
||||
}
|
||||
|
||||
func (s *rpcServer) deregisterLocal() {
|
||||
name := s.Options().Name
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
network.Deregister(name)
|
||||
}
|
||||
@@ -571,9 +571,6 @@ func (s *rpcServer) Start() error {
|
||||
// Keep the service registered to registry
|
||||
go s.registrar(listener, addr, config, exit)
|
||||
|
||||
// Make this server reachable in-process for the client fast-path.
|
||||
s.registerLocal()
|
||||
|
||||
s.setStarted(true)
|
||||
|
||||
return nil
|
||||
@@ -584,8 +581,6 @@ func (s *rpcServer) Stop() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.deregisterLocal()
|
||||
|
||||
ch := make(chan error)
|
||||
s.exit <- ch
|
||||
|
||||
|
||||
+1
-11
@@ -8,7 +8,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
@@ -82,16 +81,7 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
||||
return resp, fmt.Errorf("x402: 402 response carried no requirements")
|
||||
}
|
||||
reqd := ch.Accepts[0]
|
||||
// The amount governs the whole spend cap, so it must be a real positive
|
||||
// integer. A swallowed parse error (non-decimal, overflow, empty) would
|
||||
// yield 0 and pass the budget check trivially, and a negative amount would
|
||||
// inflate the remaining allowance — either way the cap is defeated. Refuse
|
||||
// before signing anything.
|
||||
amount, err := strconv.ParseInt(strings.TrimSpace(reqd.MaxAmountRequired), 10, 64)
|
||||
if err != nil || amount <= 0 {
|
||||
return resp, fmt.Errorf("x402: refusing to pay %s: invalid maxAmountRequired %q",
|
||||
reqd.Resource, reqd.MaxAmountRequired)
|
||||
}
|
||||
amount, _ := strconv.ParseInt(reqd.MaxAmountRequired, 10, 64)
|
||||
|
||||
// Spend cap: reserve before paying so concurrent calls cannot all pass
|
||||
// the check and overspend the caller's allowance. Roll the reservation
|
||||
|
||||
@@ -172,33 +172,6 @@ func TestClientBudgetReservationRollsBackOnPayError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A 402 whose maxAmountRequired is not a positive integer must be refused
|
||||
// before any payment — otherwise a swallowed parse error (0) or a negative
|
||||
// amount defeats the spend cap. The payer is never called and nothing is spent.
|
||||
func TestClientRefusesInvalidAmount(t *testing.T) {
|
||||
for _, amount := range []string{"abc", "-100", "99999999999999999999999999", "0x10", "1.5"} {
|
||||
srv := paidServer(amount)
|
||||
|
||||
payer := &mockPayer{}
|
||||
c := &Client{Payer: payer, Budget: 1_000_000}
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
resp, err := c.Do(req)
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil {
|
||||
t.Errorf("amount %q: expected refusal, got nil error", amount)
|
||||
}
|
||||
if payer.calls != 0 {
|
||||
t.Errorf("amount %q: payer called %d times, want 0", amount, payer.calls)
|
||||
}
|
||||
if c.Spent() != 0 {
|
||||
t.Errorf("amount %q: spent %d, want 0", amount, c.Spent())
|
||||
}
|
||||
srv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
type payerFunc func(context.Context, Requirements) (string, error)
|
||||
|
||||
func (f payerFunc) Pay(ctx context.Context, req Requirements) (string, error) {
|
||||
|
||||
+1
-13
@@ -126,11 +126,6 @@ type Config struct {
|
||||
// FacilitatorURL is the verify/settle endpoint used when Facilitator
|
||||
// is nil (e.g. Coinbase CDP or Alchemy).
|
||||
FacilitatorURL string `json:"facilitator,omitempty"`
|
||||
// RequireSettlement fails closed when a paid request cannot be settled:
|
||||
// if the facilitator only verifies (does not implement Settler), Require
|
||||
// refuses to serve rather than releasing the resource while no funds move.
|
||||
// Leave false only for verify-only flows where authorization is enough.
|
||||
RequireSettlement bool `json:"requireSettlement,omitempty"`
|
||||
}
|
||||
|
||||
func (c Config) network() string {
|
||||
@@ -227,14 +222,7 @@ func (c Config) Require(w http.ResponseWriter, r *http.Request, amount, resource
|
||||
}
|
||||
// Capture the funds when the facilitator can settle. Verify alone only
|
||||
// authorizes the "exact" transfer; settlement broadcasts it.
|
||||
s, canSettle := fac.(Settler)
|
||||
if c.RequireSettlement && !canSettle {
|
||||
// Fail closed: a paid config must not serve the resource on a
|
||||
// verify-only facilitator, or it gives the tool away for free.
|
||||
writeChallenge(w, req, "payment settlement unavailable")
|
||||
return false
|
||||
}
|
||||
if canSettle {
|
||||
if s, ok := fac.(Settler); ok {
|
||||
sres, err := s.Settle(r.Context(), payment, req)
|
||||
if err != nil {
|
||||
writeChallenge(w, req, "payment settlement failed: "+err.Error())
|
||||
|
||||
@@ -159,58 +159,4 @@ func TestCDPAuthorizeAttachesBearer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireSettlementFailsClosed checks that a paid config with
|
||||
// RequireSettlement refuses to serve when the facilitator only verifies (does
|
||||
// not settle) — otherwise the resource is released while no funds move.
|
||||
func TestRequireSettlementFailsClosed(t *testing.T) {
|
||||
// mockFacilitator implements Verify but not Settler.
|
||||
cfg := Config{PayTo: "0xpay", Facilitator: mockFacilitator{valid: true}, RequireSettlement: true}
|
||||
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
|
||||
r.Header.Set(PaymentHeader, "eyJ4IjoxfQ==")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
if cfg.Require(rec, r, "10000", "chat") {
|
||||
t.Fatal("Require should fail closed when settlement is required but unavailable")
|
||||
}
|
||||
if rec.Code != http.StatusPaymentRequired {
|
||||
t.Errorf("status = %d, want 402", rec.Code)
|
||||
}
|
||||
|
||||
// Without RequireSettlement the verify-only facilitator still serves.
|
||||
cfg.RequireSettlement = false
|
||||
rec = httptest.NewRecorder()
|
||||
r = httptest.NewRequest(http.MethodGet, "/tool", nil)
|
||||
r.Header.Set(PaymentHeader, "eyJ4IjoxfQ==")
|
||||
if !cfg.Require(rec, r, "10000", "chat") {
|
||||
t.Fatalf("verify-only should serve when settlement is not required; body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireSettlementServesWithSettler checks that a paid config with
|
||||
// RequireSettlement serves when the facilitator can settle.
|
||||
func TestRequireSettlementServesWithSettler(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/verify":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"isValid": true})
|
||||
case "/settle":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "transaction": "0xabc"})
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// HTTPFacilitator implements Settler.
|
||||
cfg := Config{PayTo: "0xpay", FacilitatorURL: srv.URL, RequireSettlement: true}
|
||||
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
|
||||
r.Header.Set(PaymentHeader, "eyJ4IjoxfQ==")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
if !cfg.Require(rec, r, "10000", "chat") {
|
||||
t.Fatalf("Require should serve with a settling facilitator; body=%s", rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get(PaymentResponseHeader); got != "0xabc" {
|
||||
t.Errorf("settlement header = %q, want 0xabc", got)
|
||||
}
|
||||
}
|
||||
|
||||
var _ Settler = (*HTTPFacilitator)(nil)
|
||||
|
||||
Reference in New Issue
Block a user