Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de84381b40 |
@@ -1,8 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 🔒 Report a vulnerability
|
||||
url: https://github.com/micro/go-micro/security/advisories/new
|
||||
about: Privately disclose security vulnerabilities to the maintainers.
|
||||
- name: 💖 Sponsor Go Micro
|
||||
url: https://github.com/sponsors/asim
|
||||
about: Fund ongoing development and see your name or logo on the project.
|
||||
|
||||
@@ -21,8 +21,9 @@ changes, architectural rewrites. Those go to the human.
|
||||
|
||||
## Work queue (ranked)
|
||||
|
||||
1. **Make AtlasCloud guarded delegation pass reliably** ([#4244](https://github.com/micro/go-micro/issues/4244)) — Now-phase cross-provider conformance is the current red edge after the Minimax request-shape fallback and duplicate delegated-notification replay fixes shipped: AtlasCloud now reaches the guarded-delegation scenario, but the live agent harness still needs to satisfy the required guarded delegate within the retry budget.
|
||||
2. **Link examples wayfinding from website getting-started path** ([#4241](https://github.com/micro/go-micro/issues/4241)) — keep adoption weighted with hardening after the examples index shipped: the repo README and CLI now point at the first-agent/0→hero map, but go-micro.dev getting-started and quickstart pages should expose the same path with a CI-guarded docs smoke check.
|
||||
1. **Add a first-agent wayfinding smoke test** ([#3879](https://github.com/micro/go-micro/issues/3879)) — #3870 closed via #3877, there are no open `codex` PRs in flight, and the North Star says the adoption on-ramp must be weighted at least as highly as internal hardening. The README now gives a clear no-secret → first-agent → debugging → 0→hero path, while the website/blog story says the product promise is one runtime for services, agents, and workflows. The next highest-value increment is to make that path a checked contract so docs wayfinding cannot silently drift from the lived harness story. Keep it local/no-secret and CI-verifiable by failing when the key guide links disappear or fall out of order.
|
||||
2. **Add a no-secret inspect/debug transcript check** ([#3880](https://github.com/micro/go-micro/issues/3880)) — developer adoption is not just starting an agent; it is understanding what happened when the first conversation surprises you. Recent merged work strengthened streaming memory, run-event traces, preflight failures, and the plan/delegate handoff, so the next DX seam is proving the inner loop from `micro chat` to inspect/history with a mock provider. This is a Now-phase getting-started contract item and should point failures back to the debugging guide rather than redesigning the CLI.
|
||||
3. **Add an AP2 mandate layer over A2A and x402** ([#3552](https://github.com/micro/go-micro/issues/3552)) — this remains a forward interop investment, not a Now/Next blocker: Go Micro already has A2A agents and x402 paid tools, so a small signed-mandate foundation can keep agent payments aligned with the open-protocol story without pulling the queue ahead of adoption, resilience, streaming, or durable agent operation. Keep it additive and opt-in while the AP2/FIDO work settles.
|
||||
|
||||
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
|
||||
architecture-review pass._
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<!--
|
||||
The SECURITY prompt — go-micro's security audit. Editable policy; the workflow
|
||||
prepends the agent @mention and substitutes __ISSUE__ before posting. Keep
|
||||
__ISSUE__ literal.
|
||||
|
||||
Deliberately conservative: it does NOT auto-merge fixes, and it does NOT publish
|
||||
exploit details in public issues (responsible disclosure).
|
||||
-->
|
||||
Act as the security reviewer for go-micro. Audit for real, exploitable vulnerabilities — skip theoretical or lint-style noise.
|
||||
|
||||
GO-MICRO ATTACK SURFACE — weight these:
|
||||
- **MCP gateway** (`gateway/mcp`) and **A2A gateway** (`gateway/a2a`) — untrusted input from agents/tools: auth/scope enforcement, injection into downstream RPC, SSRF via tool/agent URLs, rate-limit/circuit-breaker bypass, info leak in errors.
|
||||
- **x402 payments** (`wrapper/x402`) — payment verification and settlement: signature/mandate validation, replay, budget-reservation races, facilitator auth (CDP bearer) handling, amount/network confusion.
|
||||
- **Auth** (`auth/jwt`, `wrapper/auth`) — token validation, algorithm confusion, scope/priority rule bypass, missing checks on endpoints.
|
||||
- **AI providers** (`ai/*`) — base-URL and endpoint handling: SSRF via config-controlled `BaseURL`, API keys leaking into logs/errors, TLS verification.
|
||||
- **Agent tool loop** (`agent/`) — prompt injection reaching real tool calls, guardrail (`MaxSteps`/`LoopLimit`/`ApproveTool`) bypass, delegate/plan side effects.
|
||||
- **Trust boundaries** — `server` RPC handlers, `broker` consumers, `store`/`registry` inputs, `transport` TLS defaults (v6 verifies by default — confirm nothing regressed).
|
||||
- **The loop itself** — `.github/workflows/loop-*.yml`: the `CODEX_TRIGGER_TOKEN` PAT must never be echoed/leaked; workflow inputs must not enable script injection.
|
||||
- **Dependencies** — run `govulncheck ./...` (install if needed) and inspect `go.mod` for known CVEs.
|
||||
|
||||
DEDUPE against open issues first.
|
||||
|
||||
HOW TO REPORT:
|
||||
- **Known/public dependency CVEs**: file a `security` issue referencing the CVE + module; you MAY open a PR bumping to the patched version. Do NOT enable auto-merge.
|
||||
- **Novel, exploitable vulnerabilities in this code** (not yet public): do NOT post an exploit or PoC in a public issue. File a CONCISE `security` + `needs-human` issue naming the class, location (file/function), and impact only — and note it should go through GitHub private vulnerability reporting. Do NOT open a public fix PR that reveals it.
|
||||
- **Low-risk hardening**: a normal `security` issue is fine.
|
||||
|
||||
NEVER auto-merge a security change. Never weaken a control to make a test pass. Architectural/breaking fixes → `needs-human` with the tradeoff.
|
||||
|
||||
Post a summary as a comment on this issue (#__ISSUE__) — findings by severity, what you filed, what needs a human — then close it (`gh issue close __ISSUE__`). If you open a dependency-bump PR: `git switch -c loop/security-__ISSUE__`, `git push -u origin loop/security-__ISSUE__`, `gh pr create --base master --label codex --label security --title "<title>" --body "<summary, Closes #__ISSUE__>"` — then STOP, do NOT run `gh pr merge --auto`. Do not use the make_pr tool.
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
harness-live:
|
||||
name: Provider harnesses (live LLM conformance)
|
||||
runs-on: ubuntu-latest
|
||||
# Only on the hourly schedule or a manual run — never automatically on
|
||||
# Only on the daily schedule or a manual run — never automatically on
|
||||
# every push/PR, so changes don't quietly burn API credits. Trigger it
|
||||
# by hand (Actions → Harness → Run workflow) when changing the agent,
|
||||
# flow, or AI internals and you want a real-model check.
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
name: "Loop: Security"
|
||||
|
||||
# Generated by `micro loop init`. A dispatch role of the autonomous loop: on a
|
||||
# cadence it opens a fresh tracking issue and posts the instruction in
|
||||
# .github/loop/prompts/security.md to the agent (@codex).
|
||||
#
|
||||
# The workflow is the MECHANISM; that prompt file is the editable POLICY —
|
||||
# change what this role does by editing the prompt, not this YAML. A FRESH
|
||||
# issue per run is deliberate: agents derive the PR branch name from the
|
||||
# triggering issue, so reusing one tracker collapses every run onto one branch.
|
||||
#
|
||||
# Gated on CODEX_TRIGGER_TOKEN: the agent ignores @mentions from the
|
||||
# github-actions bot, so dispatch posts as a real user (a PAT). No token → no-op.
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
schedule:
|
||||
- cron: "0 6 * * 1"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: loop-security
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4 # needed to read the prompt file
|
||||
- name: Dispatch security
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
|
||||
HAS_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
if [ "$HAS_TOKEN" != "true" ]; then
|
||||
echo "CODEX_TRIGGER_TOKEN is not set — skipping (the agent ignores bot @mentions)."
|
||||
exit 0
|
||||
fi
|
||||
PROMPT=".github/loop/prompts/security.md"
|
||||
if [ ! -f "$PROMPT" ]; then
|
||||
echo "missing $PROMPT — run 'micro loop init'." >&2
|
||||
exit 1
|
||||
fi
|
||||
ISSUE_URL=$(gh issue create --repo "$REPO" \
|
||||
--title "Loop: security review #$RUN_NUMBER" \
|
||||
--body "Autonomous security pass. Direction: .github/loop/NORTH_STAR.md; queue: .github/loop/PRIORITIES.md.")
|
||||
ISSUE_NUM="${ISSUE_URL##*/}"
|
||||
echo "Opened issue #$ISSUE_NUM — dispatching security."
|
||||
# The prompt file is the policy; strip its editorial <!-- --> header and
|
||||
# substitute the tracking issue number (__ISSUE__) at runtime.
|
||||
{
|
||||
echo "@codex"
|
||||
echo
|
||||
sed -e '/<!--/,/-->/d' -e "s/__ISSUE__/$ISSUE_NUM/g" "$PROMPT"
|
||||
} > "$RUNNER_TEMP/loop-body.md"
|
||||
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body-file "$RUNNER_TEMP/loop-body.md"
|
||||
@@ -16,98 +16,12 @@ next version when it ships.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **StreamAsk close cancellation** — agent streaming calls now cancel promptly when their runner closes, avoiding orphaned stream work. (`agent/`)
|
||||
- **Agent resume pending helper** — agent durability now has a focused helper for resuming pending checkpointed runs. (`agent/`)
|
||||
|
||||
### Fixed
|
||||
- **Plan/delegate retry idempotency** — agent retries now preserve side-effect and notification dedupe across conformance retry paths, including completion and owner-notification edge cases. (`agent/`, `internal/harness/`)
|
||||
|
||||
---
|
||||
|
||||
## [6.3.17] - July 2026
|
||||
|
||||
### Added
|
||||
- **First-agent examples CLI wayfinding** — `micro examples` now prints the maintained provider-free first-agent examples in copy/paste order. (`cmd/micro/`)
|
||||
- **0→hero CLI entrypoint** — `micro zero-to-hero` now points developers at the maintained no-secret services → agents → workflows harness and runnable examples. (`cmd/micro/`)
|
||||
- **First-agent tutorial smoke harness** — the first-agent tutorial path now has smoke coverage to keep the no-secret on-ramp runnable. (`internal/harness/`)
|
||||
- **No-secret agent debugging smoke** — the no-secret agent debugging path now has smoke coverage for the first-agent troubleshooting flow. (`internal/harness/`)
|
||||
- **Durable checkpoint resume smoke coverage** — durable agent resume after checkpointing now has focused smoke coverage. (`agent/`, `internal/harness/`)
|
||||
|
||||
### Fixed
|
||||
- **Plan/delegate notify replays** — duplicate and replayed plan-delegate notifications are now idempotent, so resumed runs do not duplicate completed notifications. (`agent/`, `internal/harness/`)
|
||||
- **Provider conformance scheduling** — provider conformance workflow dispatches now guard their scheduling path more reliably. (`.github/workflows/`)
|
||||
- **Plan/delegate notification completion** — delegated notifications now preserve plan completion state more reliably, including duplicate, paraphrased, and delegated-owner notification paths. (`agent/`, `internal/harness/`)
|
||||
- **AtlasCloud tool fallback** — AtlasCloud built-in tool schemas and follow-up tool fallback handling now recover conformance delegate retries more reliably. (`ai/atlascloud/`, `agent/`)
|
||||
- **Agent conformance retry completion** — conformance retry prompts and completion handling are more deterministic for delegated agent runs. (`agent/`, `internal/harness/`)
|
||||
|
||||
### Documentation
|
||||
- **First-agent quickstart numbering** — the first-agent on-ramp numbering is consistent across the README and website docs. (`README.md`, `internal/website/docs/`)
|
||||
- **First-agent inspect command** — docs now use the maintained `micro inspect agent <name>` form. (`README.md`, `internal/website/docs/`)
|
||||
- **`micro loop` quickstart wayfinding** — docs now surface the loop quickstart from the public docs index and README wayfinding. (`README.md`, `internal/website/docs/`)
|
||||
|
||||
---
|
||||
|
||||
## [6.3.16] - July 2026
|
||||
|
||||
### Added
|
||||
- **No-secret agent demo CLI** — the CLI now surfaces `micro agent demo`, making the provider-free first-agent path discoverable from the installed binary. (`cmd/micro/`)
|
||||
- **First-agent recovery doctor** — first-agent recovery checks now help diagnose install, scaffold, and provider setup issues before the live agent run. (`cmd/micro/`, `internal/website/docs/guides/`)
|
||||
|
||||
### Changed
|
||||
- **Architecture lifecycle docs** — the architecture guide now leads with the services → agents → workflows lifecycle and the first-agent on-ramp. (`internal/website/docs/architecture.md`)
|
||||
- **First-agent on-ramp** — README and website docs now lead new users through install troubleshooting, no-secret demos, the smallest first-agent example, debugging, and the 0→hero reference path in the same order. (`README.md`, `internal/website/docs/`)
|
||||
|
||||
### Fixed
|
||||
- **Config close idempotency** — config close paths now tolerate repeated closes safely. (`config/`)
|
||||
- **OpenTelemetry child span events** — agent traces now preserve child span events more reliably. (`agent/`)
|
||||
|
||||
### Documentation
|
||||
- **Security reporting** — security docs now route vulnerability reports through GitHub Security Advisories. (`SECURITY.md`, `internal/website/docs/`)
|
||||
- **Install troubleshooting** — the first-agent on-ramp now includes clearer install and PATH recovery guidance. (`internal/website/docs/guides/install-troubleshooting.md`)
|
||||
|
||||
---
|
||||
|
||||
## [6.3.15] - July 2026
|
||||
|
||||
### Added
|
||||
- **Anthropic streaming** — the Anthropic provider now supports Messages SSE streaming and is registered as a streaming-capable provider, with capability docs and parser coverage. (`ai/anthropic/`, `internal/website/docs/guides/`)
|
||||
- **AP2 mandate foundation for A2A** — the A2A gateway now has the shared payment-mandate foundation needed for AP2-style agent payment flows. (`gateway/a2a/`)
|
||||
- **Smallest first-agent example** — a no-secret, mock-model first-agent example gives the on-ramp a minimal runnable starting point. (`examples/first-agent/`)
|
||||
|
||||
### Changed
|
||||
- **First-agent CLI next steps** — CLI output now points new users toward the maintained first-agent path after scaffold/run milestones. (`cmd/micro/`)
|
||||
|
||||
### Fixed
|
||||
- **Plan/delegate completion** — plan-delegate runs now preserve completed steps, guard ordering, require notify-before-completion, and stabilize checkpoint continuation paths. (`agent/`, `internal/harness/`)
|
||||
- **Provider text tool calls** — AtlasCloud and weaker-model fallback paths now recover tagged, `Create`-suffixed, mixed text/tool-call, and follow-up tool calls more reliably. (`agent/`, `ai/atlascloud/`)
|
||||
- **First-agent broker isolation** — the first-agent harness now isolates broker state more reliably across runs. (`internal/harness/`)
|
||||
|
||||
### Documentation
|
||||
- **First-agent example path** — docs and website wayfinding now surface the smallest example, no-secret transcript, and 0→hero path together. (`README.md`, `internal/website/docs/`)
|
||||
- **Agent operations guidance** — agent debugging docs now include operational failure guidance, inspect hints, and durable resume pointers. (`internal/website/docs/guides/`)
|
||||
|
||||
---
|
||||
|
||||
## [6.3.14] - July 2026
|
||||
|
||||
### Added
|
||||
- **MiniMax provider** — run agents against MiniMax's `MiniMax-M3` model via its OpenAI-compatible endpoint, with tool calling and streaming; auto-detected from the base URL. (`ai/minimax/`)
|
||||
- **`micro loop` security role** — a new opt-in loop role (`--roles …,security`) that periodically audits a repo for vulnerabilities and files `security` issues. It is deliberately conservative: it never auto-merges fixes and never publishes exploit detail in public issues (responsible disclosure), and risky fixes are marked `needs-human`. go-micro now runs it against its own attack surface (MCP/A2A gateways, x402, auth, provider URLs, agent tool loop, deps). (`cmd/micro/loop/`)
|
||||
- **Agent run tracing** — agent model streaming and run-event kinds now emit richer trace detail for debugging agent execution. (`agent/`)
|
||||
|
||||
### Changed
|
||||
- **Agent memory** — streamed agent replies are persisted in conversation memory so later turns can reference streamed responses. (`agent/`)
|
||||
|
||||
### Fixed
|
||||
- **Plan/delegate completion** — agents now continue unfinished plan steps more reliably, fail checkpointed runs that leave delegated plans unfinished, recover from unknown plan-delegate tool calls, avoid duplicate side effects, and complete timeout paths deterministically. (`agent/`)
|
||||
- **AtlasCloud tool calls** — streaming and request fallback handling now recovers tool-call results from provider responses that omit the expected structured fields. (`ai/atlascloud/`)
|
||||
- **Agent preflight diagnostics** — provider setup failures now surface more actionable errors before an agent run starts. (`agent/`)
|
||||
- **A2A fallback streams** — fallback stream validation is stricter for malformed or incomplete A2A streaming responses. (`gateway/a2a/`)
|
||||
- **File-store test isolation** — file-store expiry and table tests are less timing-sensitive and isolate their state more reliably. (`store/file/`)
|
||||
|
||||
### Documentation
|
||||
- **First-agent debugging path** — docs now include no-secret transcript checkpoints, durable resume examples, and clearer CLI/website wayfinding for first-agent debugging. (`README.md`, `internal/website/docs/`, `examples/agent-durable/`)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ Running Go Micro in production, or building on it and want help? Paid **support,
|
||||
- [Building Agents](#building-agents) — [Plan & Delegate](#plan--delegate), [Pluggable](#batteries-included-pluggable), [Paid tools (x402)](#paid-tools-x402), [A2A](#reachable-by-other-agents-a2a)
|
||||
- [Features](#features)
|
||||
- [CLI](#cli)
|
||||
- [Autonomous improvement loop](#autonomous-improvement-loop)
|
||||
- [Multi-Service Projects](#multi-service-projects)
|
||||
- [Data Model](#data-model)
|
||||
- [AI Providers](#ai-providers)
|
||||
@@ -51,8 +50,6 @@ curl -fsSL https://go-micro.dev/install.sh | sh
|
||||
go install go-micro.dev/v6/cmd/micro@latest
|
||||
```
|
||||
|
||||
If install or `PATH` checks fail, use the [install troubleshooting guide](internal/website/docs/guides/install-troubleshooting.md) before scaffolding your first service.
|
||||
|
||||
### Fastest start — no API key
|
||||
|
||||
Scaffold a service, run it, call it:
|
||||
@@ -90,42 +87,17 @@ make harness
|
||||
After install and the first `micro new`/`micro run` smoke check, take the
|
||||
walkable agent path in this order:
|
||||
|
||||
1. [Install troubleshooting](internal/website/docs/guides/install-troubleshooting.md) — verify the binary installer or `go install`, `PATH`, `micro --version`, and the no-secret smoke path before agent work.
|
||||
2. `micro agent demo` — print the provider-free first-agent demo command and next docs steps from the installed CLI.
|
||||
3. `micro examples` — print the maintained provider-free runnable examples in copy/paste order.
|
||||
4. `micro zero-to-hero` — print the maintained one-command no-secret lifecycle harness and runnable examples.
|
||||
5. [Examples wayfinding index](examples/INDEX.md) — choose the smallest no-secret first-agent, maintained [0→hero support reference](examples/support/), and next interop examples from one map.
|
||||
6. [Smallest first-agent example](examples/first-agent/) — run one service-backed agent with a mock model and no provider key.
|
||||
7. [No-secret first-agent transcript](internal/website/docs/guides/no-secret-first-agent.md) — run the
|
||||
1. [No-secret first-agent transcript](internal/website/docs/guides/no-secret-first-agent.md) — run the
|
||||
maintained support agent with a mock model and see services → agents → workflows succeed without a key.
|
||||
8. [Your First Agent](internal/website/docs/guides/your-first-agent.md) — build a
|
||||
2. [Your First Agent](internal/website/docs/guides/your-first-agent.md) — build a
|
||||
service-backed agent and talk to it with `micro chat`.
|
||||
9. [Debugging your agent](internal/website/docs/guides/debugging-agents.md) — use
|
||||
`micro inspect agent <name>`, run history, memory, and provider checks when the first
|
||||
3. [Debugging your agent](internal/website/docs/guides/debugging-agents.md) — use
|
||||
`micro agent inspect`, run history, memory, and provider checks when the first
|
||||
conversation does something unexpected.
|
||||
10. [0→hero Reference](internal/website/docs/guides/zero-to-hero.md) — complete the
|
||||
4. [0→hero Reference](internal/website/docs/guides/zero-to-hero.md) — complete the
|
||||
services → agents → workflows loop with scaffold, run, chat, inspect, flow
|
||||
history, and deploy dry-run commands that match the maintained harness.
|
||||
|
||||
### Autonomous improvement loop
|
||||
|
||||
Want the same services → agents → workflows lifecycle applied to your
|
||||
repository? `micro loop` scaffolds the autonomous improvement loop used by Go
|
||||
Micro itself: a North Star, ranked issue queue, role prompts, GitHub Actions
|
||||
workflows, and verification for CI-gated PRs.
|
||||
|
||||
```bash
|
||||
micro loop init --roles all
|
||||
micro loop verify
|
||||
```
|
||||
|
||||
Before turning on the schedule, configure a dispatch token such as
|
||||
`CODEX_TRIGGER_TOKEN`, protect the default branch with required CI checks
|
||||
(`go build ./...`, `go test ./...`, and `golangci-lint run ./...` for this
|
||||
repository), and seed `.github/loop/PRIORITIES.md` with one scoped issue per
|
||||
increment. See the [`micro loop` quickstart](internal/website/docs/guides/micro-loop.md)
|
||||
for the setup checklist and operating model.
|
||||
|
||||
### Generate from a prompt — with an LLM key
|
||||
|
||||
Set a provider key, describe what you want, and the AI designs services, writes handlers, compiles, and starts them:
|
||||
@@ -467,12 +439,9 @@ resp, _ := m.Generate(ctx, &ai.Request{Prompt: "hello"})
|
||||
|
||||
## Examples
|
||||
|
||||
New to agents? Follow the [first-agent on-ramp](#first-agent-on-ramp), then use the [examples index](examples/README.md) for the full services → agents → workflows map.
|
||||
|
||||
- [hello-world](examples/hello-world/) — Basic RPC service
|
||||
- [multi-service](examples/multi-service/) — Multiple services in one binary
|
||||
- [mcp](examples/mcp/) — MCP integration with AI agents
|
||||
- [first-agent](examples/first-agent/) — Smallest provider-free service-backed agent
|
||||
- [agent-plan-delegate](examples/agent-plan-delegate/) — Agent planning and multi-agent delegation
|
||||
- [agent-durable](examples/agent-durable/) — Checkpoint and resume an agent run without replaying completed tool side effects
|
||||
- [grpc-interop](examples/grpc-interop/) — Call go-micro from any gRPC client
|
||||
|
||||
+4
-3
@@ -17,10 +17,10 @@ We actively support the following versions of go-micro:
|
||||
|
||||
### How to Report
|
||||
|
||||
Use GitHub's private security advisory feature:
|
||||
https://github.com/micro/go-micro/security/advisories/new
|
||||
Send security vulnerability reports to: **security@go-micro.dev**
|
||||
|
||||
This keeps vulnerability reports private, ties follow-up to the affected repository, and avoids relying on project email routing.
|
||||
Or use GitHub's private security advisory feature:
|
||||
https://github.com/micro/go-micro/security/advisories/new
|
||||
|
||||
### What to Include
|
||||
|
||||
@@ -175,4 +175,5 @@ We currently do not offer a bug bounty program, but we greatly appreciate respon
|
||||
For security questions that are not vulnerabilities, please:
|
||||
- Open a discussion: https://github.com/micro/go-micro/discussions
|
||||
- Join Discord: https://discord.gg/G8Gk5j3uXr
|
||||
- Email: support@go-micro.dev
|
||||
|
||||
|
||||
+4
-53
@@ -99,12 +99,6 @@ type agentImpl struct {
|
||||
// holding mu. Tool execution updates it so resumed runs can reuse
|
||||
// completed tool results without replaying side effects.
|
||||
currentRun *flow.Run
|
||||
|
||||
// delegateCalls collapses concurrent equivalent delegate tool calls so a
|
||||
// provider replay cannot fan out duplicate delegated side effects before the
|
||||
// durable delegate-result cache is written.
|
||||
delegateMu sync.Mutex
|
||||
delegateCalls map[string]*delegateCall
|
||||
}
|
||||
|
||||
// New creates a new Agent.
|
||||
@@ -250,31 +244,6 @@ func Pending(ctx context.Context, ag Agent) ([]flow.Run, error) {
|
||||
return a.pending(ctx)
|
||||
}
|
||||
|
||||
// ResumePending resumes every checkpointed agent run that has not completed
|
||||
// yet, in the same oldest-first order returned by Pending.
|
||||
//
|
||||
// It is a convenience for service startup and recovery loops: after recreating
|
||||
// an agent with the same checkpoint store, call ResumePending to drain the
|
||||
// durable backlog without listing and resuming each run manually. If any run
|
||||
// fails again, ResumePending stops and returns that run id with the error so
|
||||
// callers can log, alert, or retry later without hiding the failing run.
|
||||
func ResumePending(ctx context.Context, ag Agent) (string, error) {
|
||||
a, ok := ag.(*agentImpl)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("agent resume pending: unsupported agent implementation %T", ag)
|
||||
}
|
||||
runs, err := a.pending(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, run := range runs {
|
||||
if _, err := a.resume(ctx, run.ID); err != nil {
|
||||
return run.ID, err
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (a *agentImpl) ask(ctx context.Context, message, parentRunID string) (*Response, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
@@ -328,11 +297,7 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
|
||||
}
|
||||
}
|
||||
|
||||
// Some providers satisfy a saved plan one outstanding item per turn,
|
||||
// especially when the final item delegates to another agent. Allow enough
|
||||
// continuations for the services → agents → workflows harness to complete
|
||||
// every planned side effect without weakening the final unfinished-plan guard.
|
||||
const maxPlanCompletionTurns = 6
|
||||
const maxPlanCompletionTurns = 3
|
||||
var resp *ai.Response
|
||||
for planCompletionTurn := 0; ; planCompletionTurn++ {
|
||||
resp, err = ai.GenerateWithRetry(ctx, a.model, &ai.Request{
|
||||
@@ -347,7 +312,6 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
|
||||
})
|
||||
if err != nil {
|
||||
run.Status = agentRunFailureStatus(err)
|
||||
err = agentOperationalError(err)
|
||||
if a.currentRun != nil {
|
||||
run.Steps = a.currentRun.Steps
|
||||
}
|
||||
@@ -387,15 +351,6 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
|
||||
resp.Reply = ""
|
||||
}
|
||||
}
|
||||
} else if calls, answer, ok := a.executeAdditionalTextToolCalls(ctx, resp.Reply, toolList, resp.ToolCalls); ok {
|
||||
resp.ToolCalls = append(resp.ToolCalls, calls...)
|
||||
if answer != "" {
|
||||
if resp.Answer == "" {
|
||||
resp.Answer = answer
|
||||
} else {
|
||||
resp.Answer += "\n" + answer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if a.opts.Checkpoint != nil {
|
||||
@@ -406,7 +361,7 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
|
||||
if resp.Answer != "" {
|
||||
a.mem.Add("assistant", resp.Answer)
|
||||
}
|
||||
message = fmt.Sprintf("Continue the same run by calling the required tool(s) for the unfinished plan steps below. Do not repeat completed work, do not provide a final answer yet, and complete at least one unfinished step this turn if a matching tool is available. Unfinished plan steps: %s", strings.Join(unfinished, ", "))
|
||||
message = "Continue the run. These plan steps are still unfinished and must be completed before a final answer: " + strings.Join(unfinished, ", ")
|
||||
a.mem.Add("user", message)
|
||||
messages = a.mem.Messages()
|
||||
continue
|
||||
@@ -504,7 +459,7 @@ func (a *agentImpl) Run() error {
|
||||
a.setup()
|
||||
}
|
||||
|
||||
serverOpts := []server.Option{
|
||||
a.server = server.NewServer(
|
||||
server.Name(a.opts.Name),
|
||||
server.Address(a.opts.Address),
|
||||
server.Registry(a.opts.Registry),
|
||||
@@ -512,11 +467,7 @@ func (a *agentImpl) Run() error {
|
||||
"type": "agent",
|
||||
"services": strings.Join(a.opts.Services, ","),
|
||||
}),
|
||||
}
|
||||
if a.opts.Broker != nil {
|
||||
serverOpts = append(serverOpts, server.Broker(a.opts.Broker))
|
||||
}
|
||||
a.server = server.NewServer(serverOpts...)
|
||||
)
|
||||
|
||||
_ = pb.RegisterAgentHandler(a.server, a)
|
||||
|
||||
|
||||
+12
-246
@@ -2,7 +2,6 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -27,11 +26,6 @@ const (
|
||||
toolHumanInput = "request_input"
|
||||
)
|
||||
|
||||
type delegateCall struct {
|
||||
done chan struct{}
|
||||
res ai.ToolResult
|
||||
}
|
||||
|
||||
// builtinTools returns the tool definitions exposed to the model in
|
||||
// addition to the agent's scoped service tools.
|
||||
func builtinTools() []ai.Tool {
|
||||
@@ -296,11 +290,6 @@ func (a *agentImpl) planWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
if call.Name == toolPlan {
|
||||
return a.handlePlan(call)
|
||||
}
|
||||
if call.Name == toolDelegate {
|
||||
if blocked := a.unfinishedPlanStepsBeforeDelegation(); len(blocked) > 0 {
|
||||
return refused(call.ID, ai.RefusedApproval, "complete these plan steps before delegating: "+strings.Join(blocked, ", "))
|
||||
}
|
||||
}
|
||||
res := next(ctx, call)
|
||||
if res.Refused == "" && toolErrorMessage(res) == "" {
|
||||
a.completeNextPlanStep()
|
||||
@@ -375,95 +364,12 @@ func (a *agentImpl) approveWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
// handlePlan persists the supplied plan to the agent's memory and
|
||||
// echoes it back so the model can see the stored state.
|
||||
func (a *agentImpl) handlePlan(call ai.ToolCall) ai.ToolResult {
|
||||
input := preserveCompletedPlanSteps(a.loadPlan(), call.Input)
|
||||
data, err := json.Marshal(input)
|
||||
data, err := json.Marshal(call.Input)
|
||||
if err != nil {
|
||||
return errResult(call.ID, "invalid plan: "+err.Error())
|
||||
}
|
||||
_ = a.stateStore().Write(&store.Record{Key: planKey, Value: data})
|
||||
return ai.ToolResult{ID: call.ID, Value: input, Content: string(data)}
|
||||
}
|
||||
|
||||
func preserveCompletedPlanSteps(stored string, input map[string]any) map[string]any {
|
||||
if stored == "" {
|
||||
return input
|
||||
}
|
||||
var previous map[string]any
|
||||
if err := json.Unmarshal([]byte(stored), &previous); err != nil {
|
||||
return input
|
||||
}
|
||||
completed := completedPlanTasks(previous)
|
||||
if len(completed) == 0 {
|
||||
return input
|
||||
}
|
||||
steps, ok := input["steps"].([]any)
|
||||
if !ok {
|
||||
return input
|
||||
}
|
||||
for _, raw := range steps {
|
||||
step, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
task, _ := step["task"].(string)
|
||||
if completed[planTaskCompletionKey(task)] && isUnfinishedPlanStatus(step["status"]) {
|
||||
step["status"] = "done"
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func completedPlanTasks(plan map[string]any) map[string]bool {
|
||||
steps, ok := plan["steps"].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
completed := map[string]bool{}
|
||||
for _, raw := range steps {
|
||||
step, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
status, _ := step["status"].(string)
|
||||
if status != "done" {
|
||||
continue
|
||||
}
|
||||
task, _ := step["task"].(string)
|
||||
if task = planTaskCompletionKey(task); task != "" {
|
||||
completed[task] = true
|
||||
}
|
||||
}
|
||||
return completed
|
||||
}
|
||||
|
||||
func normalizePlanTask(task string) string {
|
||||
return strings.Join(strings.Fields(strings.ToLower(task)), " ")
|
||||
}
|
||||
|
||||
func planTaskCompletionKey(task string) string {
|
||||
normalized := normalizePlanTask(task)
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
if isLaunchReadinessDelegationPlanTask(normalized) {
|
||||
return "launch-readiness-notification"
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func isLaunchReadinessDelegationPlanTask(task string) bool {
|
||||
task = normalizePlanTask(task)
|
||||
if !strings.Contains(task, "notify") && !strings.Contains(task, "notification") {
|
||||
return false
|
||||
}
|
||||
hasLaunchReadiness := strings.Contains(task, "launch") || strings.Contains(task, "readiness") || strings.Contains(task, "ready")
|
||||
hasOwnerComms := strings.Contains(task, "owner") && strings.Contains(task, "comms")
|
||||
return hasLaunchReadiness || hasOwnerComms
|
||||
}
|
||||
|
||||
func isUnfinishedPlanStatus(status any) bool {
|
||||
s, _ := status.(string)
|
||||
return s == "" || s == "pending" || s == "in_progress"
|
||||
return ai.ToolResult{ID: call.ID, Value: call.Input, Content: string(data)}
|
||||
}
|
||||
|
||||
func (a *agentImpl) completeNextPlanStep() {
|
||||
@@ -496,53 +402,6 @@ func (a *agentImpl) completeNextPlanStep() {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agentImpl) unfinishedPlanStepsBeforeDelegation() []string {
|
||||
plan := a.loadPlan()
|
||||
if plan == "" {
|
||||
return nil
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(plan), &data); err != nil {
|
||||
return nil
|
||||
}
|
||||
steps, ok := data["steps"].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var unfinished []string
|
||||
for _, raw := range steps {
|
||||
step, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
task := planStepTask(step)
|
||||
if isDelegationPlanTask(task) {
|
||||
break
|
||||
}
|
||||
if !isUnfinishedPlanStatus(step["status"]) {
|
||||
continue
|
||||
}
|
||||
if task == "" {
|
||||
task = "<unnamed>"
|
||||
}
|
||||
unfinished = append(unfinished, task)
|
||||
}
|
||||
return unfinished
|
||||
}
|
||||
|
||||
func planStepTask(step map[string]any) string {
|
||||
if task, _ := step["task"].(string); task != "" {
|
||||
return task
|
||||
}
|
||||
desc, _ := step["description"].(string)
|
||||
return desc
|
||||
}
|
||||
|
||||
func isDelegationPlanTask(task string) bool {
|
||||
task = normalizePlanTask(task)
|
||||
return strings.Contains(task, "delegate") || strings.Contains(task, "notify") || strings.Contains(task, "notification")
|
||||
}
|
||||
|
||||
func (a *agentImpl) unfinishedPlanSteps() []string {
|
||||
plan := a.loadPlan()
|
||||
if plan == "" {
|
||||
@@ -566,7 +425,7 @@ func (a *agentImpl) unfinishedPlanSteps() []string {
|
||||
if status != "" && status != "pending" && status != "in_progress" {
|
||||
continue
|
||||
}
|
||||
task := planStepTask(step)
|
||||
task, _ := step["task"].(string)
|
||||
if task == "" {
|
||||
task = "<unnamed>"
|
||||
}
|
||||
@@ -590,22 +449,13 @@ func (a *agentImpl) handleHumanInput(call ai.ToolCall) ai.ToolResult {
|
||||
// if 'to' names a registered agent, it is called via RPC. Otherwise an
|
||||
// ephemeral sub-agent is created with a fresh, isolated context, asked
|
||||
// the subtask, and its reply returned.
|
||||
func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) (res ai.ToolResult) {
|
||||
func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
input := call.Input
|
||||
task, _ := input["task"].(string)
|
||||
if task == "" {
|
||||
return errResult(call.ID, "task is required")
|
||||
}
|
||||
to, _ := input["to"].(string)
|
||||
if cached, ok := a.cachedDelegateResult(call.ID, to, task); ok {
|
||||
return cached
|
||||
}
|
||||
|
||||
key := delegateResultKey(to, task)
|
||||
if cached, ok := a.joinDelegateCall(ctx, call.ID, key); ok {
|
||||
return cached
|
||||
}
|
||||
defer func() { a.finishDelegateCall(key, res) }()
|
||||
|
||||
// An external agent on another framework, addressed by A2A URL.
|
||||
if strings.HasPrefix(to, "http://") || strings.HasPrefix(to, "https://") {
|
||||
@@ -613,7 +463,9 @@ func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) (res a
|
||||
if err != nil {
|
||||
return errResult(call.ID, "delegate to A2A agent "+to+": "+err.Error())
|
||||
}
|
||||
return a.storeDelegateResult(call.ID, to, task, map[string]any{"agent": to, "reply": reply})
|
||||
out := map[string]any{"agent": to, "reply": reply}
|
||||
b, _ := json.Marshal(out)
|
||||
return ai.ToolResult{ID: call.ID, Value: out, Content: string(b)}
|
||||
}
|
||||
|
||||
// Delegate-first: an existing agent that owns the domain handles it.
|
||||
@@ -622,7 +474,9 @@ func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) (res a
|
||||
if err != nil {
|
||||
return errResult(call.ID, "delegate to agent "+to+": "+err.Error())
|
||||
}
|
||||
return a.storeDelegateResult(call.ID, to, task, map[string]any{"agent": to, "reply": reply})
|
||||
out := map[string]any{"agent": to, "reply": reply}
|
||||
b, _ := json.Marshal(out)
|
||||
return ai.ToolResult{ID: call.ID, Value: out, Content: string(b)}
|
||||
}
|
||||
|
||||
// Otherwise create a focused, ephemeral sub-agent. Fresh context:
|
||||
@@ -655,97 +509,9 @@ func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) (res a
|
||||
if err != nil {
|
||||
return errResult(call.ID, "sub-agent: "+err.Error())
|
||||
}
|
||||
return a.storeDelegateResult(call.ID, to, task, map[string]any{"reply": resp.Reply})
|
||||
}
|
||||
|
||||
func (a *agentImpl) joinDelegateCall(ctx context.Context, id, key string) (ai.ToolResult, bool) {
|
||||
a.delegateMu.Lock()
|
||||
if a.delegateCalls == nil {
|
||||
a.delegateCalls = map[string]*delegateCall{}
|
||||
}
|
||||
if inFlight := a.delegateCalls[key]; inFlight != nil {
|
||||
a.delegateMu.Unlock()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return errResult(id, ctx.Err().Error()), true
|
||||
case <-inFlight.done:
|
||||
return withToolResultID(inFlight.res, id), true
|
||||
}
|
||||
}
|
||||
a.delegateCalls[key] = &delegateCall{done: make(chan struct{})}
|
||||
a.delegateMu.Unlock()
|
||||
return ai.ToolResult{}, false
|
||||
}
|
||||
|
||||
func (a *agentImpl) finishDelegateCall(key string, res ai.ToolResult) {
|
||||
a.delegateMu.Lock()
|
||||
inFlight := a.delegateCalls[key]
|
||||
if inFlight == nil {
|
||||
a.delegateMu.Unlock()
|
||||
return
|
||||
}
|
||||
inFlight.res = res
|
||||
delete(a.delegateCalls, key)
|
||||
close(inFlight.done)
|
||||
a.delegateMu.Unlock()
|
||||
}
|
||||
|
||||
func (a *agentImpl) cachedDelegateResult(id, to, task string) (ai.ToolResult, bool) {
|
||||
recs, err := a.stateStore().Read(delegateResultKey(to, task))
|
||||
if err != nil || len(recs) == 0 {
|
||||
return ai.ToolResult{}, false
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(recs[0].Value, &out); err != nil {
|
||||
return ai.ToolResult{}, false
|
||||
}
|
||||
out := map[string]any{"reply": resp.Reply}
|
||||
b, _ := json.Marshal(out)
|
||||
return ai.ToolResult{ID: id, Value: out, Content: string(b)}, true
|
||||
}
|
||||
|
||||
func (a *agentImpl) storeDelegateResult(id, to, task string, out map[string]any) ai.ToolResult {
|
||||
b, _ := json.Marshal(out)
|
||||
_ = a.stateStore().Write(&store.Record{Key: delegateResultKey(to, task), Value: b})
|
||||
return ai.ToolResult{ID: id, Value: out, Content: string(b)}
|
||||
}
|
||||
|
||||
func withToolResultID(res ai.ToolResult, id string) ai.ToolResult {
|
||||
res.ID = id
|
||||
return res
|
||||
}
|
||||
|
||||
func delegateResultKey(to, task string) string {
|
||||
fp := normalizeDelegateTarget(to) + "\x00" + normalizeDelegateTask(task)
|
||||
sum := sha256.Sum256([]byte(fp))
|
||||
return fmt.Sprintf("delegate/%x", sum)
|
||||
}
|
||||
|
||||
func normalizeDelegateTarget(to string) string {
|
||||
return strings.Join(strings.Fields(strings.ToLower(strings.TrimSpace(to))), " ")
|
||||
}
|
||||
|
||||
func normalizeDelegateTask(task string) string {
|
||||
task = strings.ToLower(strings.TrimSpace(task))
|
||||
task = strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
return r
|
||||
case r == '@':
|
||||
return r
|
||||
default:
|
||||
return ' '
|
||||
}
|
||||
}, task)
|
||||
task = strings.Join(strings.Fields(task), " ")
|
||||
if strings.Contains(task, "notify") &&
|
||||
strings.Contains(task, "owner") &&
|
||||
strings.Contains(task, "acme") &&
|
||||
strings.Contains(task, "launch") &&
|
||||
strings.Contains(task, "plan") &&
|
||||
(strings.Contains(task, "ready") || strings.Contains(task, "readiness") || strings.Contains(task, "prepared") || strings.Contains(task, "complete")) {
|
||||
return "notify owner@acme.com launch-plan-ready"
|
||||
}
|
||||
return task
|
||||
return ai.ToolResult{ID: call.ID, Value: out, Content: string(b)}
|
||||
}
|
||||
|
||||
// isAgent reports whether name resolves to a registered agent (a
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/registry"
|
||||
@@ -55,53 +52,6 @@ func TestHandlePlanPersists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePlanPreservesCompletedSteps(t *testing.T) {
|
||||
mem := store.NewMemoryStore()
|
||||
a := New(Name("planner"), WithStore(mem)).(*agentImpl)
|
||||
|
||||
a.handlePlan(ai.ToolCall{Name: "plan", Input: map[string]any{
|
||||
"steps": []any{
|
||||
map[string]any{"task": "create Design task", "status": "done"},
|
||||
map[string]any{"task": "Delegate readiness notification to comms agent", "status": "done"},
|
||||
},
|
||||
}})
|
||||
|
||||
res := a.handlePlan(ai.ToolCall{Name: "plan", Input: map[string]any{
|
||||
"steps": []any{
|
||||
map[string]any{"task": "create Design task", "status": "done"},
|
||||
map[string]any{"task": " delegate readiness notification TO comms agent ", "status": "in_progress"},
|
||||
map[string]any{"task": "write summary", "status": "pending"},
|
||||
},
|
||||
}})
|
||||
if res.Content == "" {
|
||||
t.Fatal("handlePlan returned empty content")
|
||||
}
|
||||
if unfinished := a.unfinishedPlanSteps(); len(unfinished) != 1 || unfinished[0] != "write summary" {
|
||||
t.Fatalf("unfinished plan steps = %v, want only write summary", unfinished)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePlanPreservesCompletedLaunchReadinessNotification(t *testing.T) {
|
||||
mem := store.NewMemoryStore()
|
||||
a := New(Name("planner"), WithStore(mem)).(*agentImpl)
|
||||
|
||||
a.handlePlan(ai.ToolCall{Name: toolPlan, Input: map[string]any{
|
||||
"steps": []any{
|
||||
map[string]any{"task": "notify owner via comms", "status": "done"},
|
||||
},
|
||||
}})
|
||||
|
||||
a.handlePlan(ai.ToolCall{Name: toolPlan, Input: map[string]any{
|
||||
"steps": []any{
|
||||
map[string]any{"task": "Delegate launch readiness notification for owner@acme.com to comms agent", "status": "in_progress"},
|
||||
},
|
||||
}})
|
||||
|
||||
if unfinished := a.unfinishedPlanSteps(); len(unfinished) != 0 {
|
||||
t.Fatalf("unfinished plan steps = %v, want launch readiness notification preserved as done", unfinished)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanShowsInPrompt(t *testing.T) {
|
||||
mem := store.NewMemoryStore()
|
||||
a := New(Name("planner"), Prompt("base prompt"), WithStore(mem)).(*agentImpl)
|
||||
@@ -184,69 +134,6 @@ func TestBuiltinsAccessor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegateResultCacheReusesLaunchReadinessParaphrases(t *testing.T) {
|
||||
mem := store.NewMemoryStore()
|
||||
a := New(Name("planner"), WithStore(mem)).(*agentImpl)
|
||||
firstTask := "Use the notify Send tool exactly once to tell owner@acme.com: The launch plan is ready."
|
||||
first := a.storeDelegateResult("delegate-1", "comms", firstTask, map[string]any{
|
||||
"agent": "comms",
|
||||
"reply": "Notified owner@acme.com.",
|
||||
})
|
||||
if first.Content == "" {
|
||||
t.Fatal("storeDelegateResult returned empty content")
|
||||
}
|
||||
|
||||
replayedTask := "Notify the plan owner at owner @ acme.com that launch readiness is prepared and complete."
|
||||
cached, ok := a.cachedDelegateResult("delegate-2", " COMMS ", replayedTask)
|
||||
if !ok {
|
||||
t.Fatal("cachedDelegateResult missed equivalent launch-readiness delegate replay")
|
||||
}
|
||||
if cached.ID != "delegate-2" {
|
||||
t.Fatalf("cached result ID = %q, want replay call ID", cached.ID)
|
||||
}
|
||||
if !containsStr(cached.Content, "Notified owner@acme.com") {
|
||||
t.Fatalf("cached result content = %q, want original delegate reply", cached.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegateInFlightReplaysShareFirstResult(t *testing.T) {
|
||||
a := New(Name("planner"), WithStore(store.NewMemoryStore())).(*agentImpl)
|
||||
key := delegateResultKey("comms", "Notify owner@acme.com that the launch plan is ready")
|
||||
if _, joined := a.joinDelegateCall(context.Background(), "delegate-1", key); joined {
|
||||
t.Fatal("first delegate call unexpectedly joined an existing in-flight call")
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
results := make(chan ai.ToolResult, 1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
res, joined := a.joinDelegateCall(context.Background(), "delegate-2", key)
|
||||
if !joined {
|
||||
t.Error("replayed delegate call did not join the in-flight call")
|
||||
return
|
||||
}
|
||||
results <- res
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-results:
|
||||
t.Fatalf("replayed delegate returned before first call finished: %+v", res)
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
|
||||
first := ai.ToolResult{ID: "delegate-1", Content: `{"reply":"Notified owner@acme.com."}`}
|
||||
a.finishDelegateCall(key, first)
|
||||
wg.Wait()
|
||||
replayed := <-results
|
||||
if replayed.ID != "delegate-2" {
|
||||
t.Fatalf("replayed result ID = %q, want delegate-2", replayed.ID)
|
||||
}
|
||||
if replayed.Content != first.Content {
|
||||
t.Fatalf("replayed content = %q, want %q", replayed.Content, first.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAgent(t *testing.T) {
|
||||
reg := registry.NewMemoryRegistry()
|
||||
|
||||
@@ -278,45 +165,3 @@ func TestIsAgent(t *testing.T) {
|
||||
t.Error("isAgent(nonexistent) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanWrapBlocksDelegationUntilPriorPlanStepsFinish(t *testing.T) {
|
||||
mem := store.NewMemoryStore()
|
||||
a := New(Name("planner"), WithStore(mem)).(*agentImpl)
|
||||
a.handlePlan(ai.ToolCall{Name: toolPlan, Input: map[string]any{
|
||||
"steps": []any{
|
||||
map[string]any{"task": "Create Design task", "status": "pending"},
|
||||
map[string]any{"task": "Create Build task", "status": "pending"},
|
||||
map[string]any{"task": "Create Ship task", "status": "pending"},
|
||||
map[string]any{"task": "Delegate readiness notification to comms agent", "status": "pending"},
|
||||
},
|
||||
}})
|
||||
|
||||
called := false
|
||||
handle := a.planWrap(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
called = true
|
||||
return ai.ToolResult{ID: call.ID, Content: "ok"}
|
||||
})
|
||||
|
||||
res := handle(context.Background(), ai.ToolCall{ID: "delegate-1", Name: toolDelegate, Input: map[string]any{"to": "comms"}})
|
||||
if called {
|
||||
t.Fatal("delegate handler was called before prior task plan steps completed")
|
||||
}
|
||||
if res.Refused == "" {
|
||||
t.Fatalf("delegate result was not refused: %+v", res)
|
||||
}
|
||||
if got := res.Content; !containsStr(got, "Create Design task") || !containsStr(got, "Create Ship task") {
|
||||
t.Fatalf("delegate refusal content = %q, want prior unfinished task steps", got)
|
||||
}
|
||||
|
||||
for _, id := range []string{"add-design", "add-build", "add-ship"} {
|
||||
_ = handle(context.Background(), ai.ToolCall{ID: id, Name: "task.Add", Input: map[string]any{"title": id}})
|
||||
}
|
||||
called = false
|
||||
res = handle(context.Background(), ai.ToolCall{ID: "delegate-2", Name: toolDelegate, Input: map[string]any{"to": "comms"}})
|
||||
if !called {
|
||||
t.Fatal("delegate handler was not called after prior task plan steps completed")
|
||||
}
|
||||
if res.Refused != "" {
|
||||
t.Fatalf("delegate result refused after prior task steps completed: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-42
@@ -50,13 +50,9 @@ func (a *agentImpl) saveRun(ctx context.Context, run flow.Run) error {
|
||||
return fmt.Errorf("agent %s checkpoint save: %w", a.opts.Name, err)
|
||||
}
|
||||
if info, ok := ai.RunInfoFrom(ctx); ok {
|
||||
stage := run.State.Stage
|
||||
if stage == "" && len(run.Steps) > 0 {
|
||||
stage = run.Steps[0].Name
|
||||
}
|
||||
a.recordTimelineEvent(ctx, RunEvent{
|
||||
Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent,
|
||||
Kind: "checkpoint", Name: stage, Status: run.Status,
|
||||
Kind: "checkpoint", Name: run.State.Stage, Status: run.Status,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
@@ -195,43 +191,6 @@ func agentRunFailureStatus(err error) string {
|
||||
}
|
||||
}
|
||||
|
||||
type operationalError struct {
|
||||
err error
|
||||
hint string
|
||||
}
|
||||
|
||||
func (e *operationalError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.err.Error() + "; " + e.hint
|
||||
}
|
||||
|
||||
func (e *operationalError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.err
|
||||
}
|
||||
|
||||
func agentOperationalError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
switch ai.ClassifyError(err) {
|
||||
case ai.ErrorKindCanceled:
|
||||
return &operationalError{err: err, hint: "agent run canceled; inspect run history with `micro inspect agent <name> --status canceled` or see docs/guides/debugging-agents.md"}
|
||||
case ai.ErrorKindTimeout:
|
||||
return &operationalError{err: err, hint: "agent provider call timed out; inspect run history with `micro inspect agent <name> --status timeout`, then adjust AgentModelCallTimeout/AgentModelRetry or see docs/guides/debugging-agents.md"}
|
||||
case ai.ErrorKindRateLimited:
|
||||
return &operationalError{err: err, hint: "agent provider was rate limited; inspect run history with `micro inspect agent <name> --status rate_limited`, check provider keys with `micro agent preflight`, or see docs/guides/debugging-agents.md"}
|
||||
case ai.ErrorKindUnavailable:
|
||||
return &operationalError{err: err, hint: "agent provider appears temporarily unavailable; retry with bounded AgentModelRetry and verify provider setup with `micro agent preflight` or docs/guides/debugging-agents.md"}
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agentImpl) checkpointToolWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
if a.opts.Checkpoint == nil || a.currentRun == nil {
|
||||
|
||||
+2
-156
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/client"
|
||||
@@ -220,76 +219,9 @@ func TestCheckpointContinuesRunWithUnfinishedPlanStep(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointContinuesRunThroughSeveralSingleStepTurns(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "single-step-plan-agent")
|
||||
|
||||
completed := []string{}
|
||||
modelCalls := 0
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
modelCalls++
|
||||
if opts.ToolHandler == nil {
|
||||
t.Fatal("missing tool handler")
|
||||
}
|
||||
switch modelCalls {
|
||||
case 1:
|
||||
opts.ToolHandler(ctx, ai.ToolCall{ID: "plan-1", Name: toolPlan, Input: map[string]any{
|
||||
"steps": []any{
|
||||
map[string]any{"task": "create Design task", "status": "pending"},
|
||||
map[string]any{"task": "create Build task", "status": "pending"},
|
||||
map[string]any{"task": "create Ship task", "status": "pending"},
|
||||
map[string]any{"task": "delegate readiness notification", "status": "pending"},
|
||||
},
|
||||
}})
|
||||
return &ai.Response{Reply: "planned"}, nil
|
||||
case 2, 3, 4, 5:
|
||||
want := []string{"create Design task", "create Build task", "create Ship task", "delegate readiness notification"}[modelCalls-2]
|
||||
if !strings.Contains(req.Prompt, want) {
|
||||
t.Fatalf("continuation prompt %d = %q, want %q", modelCalls, req.Prompt, want)
|
||||
}
|
||||
res := opts.ToolHandler(ctx, ai.ToolCall{ID: want, Name: "external.step", Input: map[string]any{"step": want}})
|
||||
if res.Content != "completed "+want {
|
||||
t.Fatalf("tool result = %q, want completed %s", res.Content, want)
|
||||
}
|
||||
if modelCalls == 5 {
|
||||
return &ai.Response{Reply: "all plan steps complete"}, nil
|
||||
}
|
||||
return &ai.Response{Reply: "one more step complete"}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected model call %d", modelCalls)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("single-step-plan-agent"), WithCheckpoint(cp),
|
||||
WithTool("external.step", "complete one planned step", nil, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
step, _ := input["step"].(string)
|
||||
completed = append(completed, step)
|
||||
return "completed " + step, nil
|
||||
}))
|
||||
resp, err := a.Ask(ctx, "work through the launch plan")
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if resp.Reply != "all plan steps complete" {
|
||||
t.Fatalf("reply = %q, want final continuation reply", resp.Reply)
|
||||
}
|
||||
if modelCalls != 5 {
|
||||
t.Fatalf("model calls = %d, want initial plus four continuations", modelCalls)
|
||||
}
|
||||
if len(completed) != 4 {
|
||||
t.Fatalf("completed steps = %v, want four tool-backed continuations", completed)
|
||||
}
|
||||
if unfinished := a.unfinishedPlanSteps(); len(unfinished) != 0 {
|
||||
t.Fatalf("unfinished plan steps = %v, want none", unfinished)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeFailedCheckpointAfterFreshAgentRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := store.NewMemoryStore()
|
||||
cp := flow.StoreCheckpoint(st, "restart-resume-agent")
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "restart-resume-agent")
|
||||
toolRuns := 0
|
||||
modelCalls := 0
|
||||
failFirst := true
|
||||
@@ -310,7 +242,7 @@ func TestResumeFailedCheckpointAfterFreshAgentRestart(t *testing.T) {
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
newAgent := func() *agentImpl {
|
||||
return newTestAgent(Name("restart-resume-agent"), WithStore(st), WithCheckpoint(cp),
|
||||
return newTestAgent(Name("restart-resume-agent"), WithCheckpoint(cp),
|
||||
WithTool("external.provision", "provision service once", nil, func(context.Context, map[string]any) (string, error) {
|
||||
toolRuns++
|
||||
return "provisioned", nil
|
||||
@@ -332,19 +264,6 @@ func TestResumeFailedCheckpointAfterFreshAgentRestart(t *testing.T) {
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("Pending before restart returned %d runs, want 1", len(runs))
|
||||
}
|
||||
summaries, err := ListRunSummaries(st, "restart-resume-agent")
|
||||
if err != nil {
|
||||
t.Fatalf("ListRunSummaries before restart: %v", err)
|
||||
}
|
||||
if len(summaries) != 1 {
|
||||
t.Fatalf("run summaries before restart = %d, want 1", len(summaries))
|
||||
}
|
||||
if summaries[0].RunID != runs[0].ID || summaries[0].Status != "error" || summaries[0].Checkpoint != "failed" || summaries[0].Stage != agentAskStep {
|
||||
t.Fatalf("summary before restart = %#v, want failed ask checkpoint for %s", summaries[0], runs[0].ID)
|
||||
}
|
||||
if summaries[0].Events < 4 || summaries[0].LastError == "" {
|
||||
t.Fatalf("summary before restart lacks debug history/error: %#v", summaries[0])
|
||||
}
|
||||
|
||||
restarted := newAgent()
|
||||
resp, err := Resume(ctx, restarted, runs[0].ID)
|
||||
@@ -367,34 +286,6 @@ func TestResumeFailedCheckpointAfterFreshAgentRestart(t *testing.T) {
|
||||
if loaded.Status != "done" || loaded.ParentID != runs[0].ParentID {
|
||||
t.Fatalf("loaded run status/parent = %s/%s, want done/%s", loaded.Status, loaded.ParentID, runs[0].ParentID)
|
||||
}
|
||||
summaries, err = ListRunSummaries(st, "restart-resume-agent")
|
||||
if err != nil {
|
||||
t.Fatalf("ListRunSummaries after restart: %v", err)
|
||||
}
|
||||
if len(summaries) != 1 {
|
||||
t.Fatalf("run summaries after restart = %d, want 1", len(summaries))
|
||||
}
|
||||
if summaries[0].RunID != runs[0].ID || summaries[0].Status != "done" || summaries[0].Checkpoint != "done" || summaries[0].Stage != agentAskStep {
|
||||
t.Fatalf("summary after restart = %#v, want done ask checkpoint for %s", summaries[0], runs[0].ID)
|
||||
}
|
||||
if summaries[0].Events < 7 {
|
||||
t.Fatalf("summary after restart recorded %d events, want durable failure/resume/done history", summaries[0].Events)
|
||||
}
|
||||
events, err := LoadRunEvents(st, "restart-resume-agent", runs[0].ID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRunEvents after restart: %v", err)
|
||||
}
|
||||
seen := map[string]bool{"run": false, "tool": false, "checkpoint": false, "error": false, "resume": false, "done": false}
|
||||
for _, e := range events {
|
||||
if _, ok := seen[e.Kind]; ok {
|
||||
seen[e.Kind] = true
|
||||
}
|
||||
}
|
||||
for kind, ok := range seen {
|
||||
if !ok {
|
||||
t.Fatalf("events after restart missing %s: %#v", kind, events)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeFailedCheckpointDoesNotDuplicateCompactedMemory(t *testing.T) {
|
||||
@@ -463,51 +354,6 @@ func countMemoryContent(messages []ai.Message, needle string) int {
|
||||
return count
|
||||
}
|
||||
|
||||
func TestResumePendingResumesOldestAgentRunsUntilFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "resume-pending-agent")
|
||||
base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC)
|
||||
for _, run := range []flow.Run{
|
||||
{ID: "run-ok", Flow: "resume-pending-agent", Status: "failed", State: flow.State{Stage: agentAskStep, Data: []byte("ok")}, Started: base},
|
||||
{ID: "run-blocked", Flow: "resume-pending-agent", Status: "failed", State: flow.State{Stage: agentAskStep, Data: []byte("block")}, Started: base.Add(time.Minute)},
|
||||
{ID: "run-later", Flow: "resume-pending-agent", Status: "failed", State: flow.State{Stage: agentAskStep, Data: []byte("later")}, Started: base.Add(2 * time.Minute)},
|
||||
} {
|
||||
if err := cp.Save(ctx, run); err != nil {
|
||||
t.Fatalf("Save(%s): %v", run.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
var prompts []string
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
prompts = append(prompts, req.Prompt)
|
||||
if req.Prompt == "block" {
|
||||
return nil, errors.New("still blocked")
|
||||
}
|
||||
return &ai.Response{Reply: req.Prompt + " resumed"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("resume-pending-agent"), WithCheckpoint(cp))
|
||||
failedRun, err := ResumePending(ctx, a)
|
||||
if err == nil {
|
||||
t.Fatal("ResumePending succeeded, want blocked run error")
|
||||
}
|
||||
if failedRun != "run-blocked" {
|
||||
t.Fatalf("failed run = %q, want run-blocked", failedRun)
|
||||
}
|
||||
if got, want := strings.Join(prompts, ","), "ok,block"; got != want {
|
||||
t.Fatalf("prompts = %q, want %q", got, want)
|
||||
}
|
||||
loaded, ok, err := cp.Load(ctx, "run-ok")
|
||||
if err != nil || !ok || loaded.Status != "done" {
|
||||
t.Fatalf("run-ok loaded=%v err=%v status=%q, want done", ok, err, loaded.Status)
|
||||
}
|
||||
loaded, ok, err = cp.Load(ctx, "run-later")
|
||||
if err != nil || !ok || loaded.Status != "failed" {
|
||||
t.Fatalf("run-later loaded=%v err=%v status=%q, want still failed", ok, err, loaded.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingReturnsUnfinishedAgentRuns(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "pending-agent")
|
||||
|
||||
+18
-480
@@ -67,45 +67,30 @@ func runAgentConformanceScenario(t *testing.T, provider conformanceProvider) {
|
||||
}
|
||||
} else {
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
if err := validateConformanceRequest(req, opts); err != nil {
|
||||
return nil, err
|
||||
if req.Prompt == "" {
|
||||
return nil, errors.New("missing prompt")
|
||||
}
|
||||
|
||||
plan := opts.ToolHandler(ctx, ai.ToolCall{
|
||||
ID: "fake-plan-1",
|
||||
Name: "plan",
|
||||
Input: map[string]any{"steps": []map[string]any{
|
||||
{"description": "call conformance_echo", "status": "pending"},
|
||||
{"description": "attempt guarded delegate", "status": "pending"},
|
||||
}},
|
||||
})
|
||||
echo := opts.ToolHandler(ctx, ai.ToolCall{
|
||||
if len(req.Messages) == 0 || req.Messages[len(req.Messages)-1].Role != "user" {
|
||||
return nil, fmt.Errorf("missing user history: %+v", req.Messages)
|
||||
}
|
||||
if len(req.Tools) == 0 {
|
||||
return nil, errors.New("missing tools")
|
||||
}
|
||||
if opts.ToolHandler == nil {
|
||||
return nil, errors.New("missing tool handler")
|
||||
}
|
||||
res := opts.ToolHandler(ctx, ai.ToolCall{
|
||||
ID: "fake-call-1",
|
||||
Name: "conformance_echo",
|
||||
Input: map[string]any{"value": "agent-conformance"},
|
||||
})
|
||||
delegate := opts.ToolHandler(ctx, ai.ToolCall{
|
||||
ID: "fake-delegate-1",
|
||||
Name: "delegate",
|
||||
Input: map[string]any{"task": "summarize the conformance marker", "to": "blocked-reviewer"},
|
||||
})
|
||||
if plan.Content == "" {
|
||||
return nil, errors.New("empty plan result")
|
||||
}
|
||||
if echo.Content == "" {
|
||||
if res.Content == "" {
|
||||
return nil, errors.New("empty tool result")
|
||||
}
|
||||
if delegate.Refused != ai.RefusedApproval {
|
||||
return nil, fmt.Errorf("delegate refusal = %q, want %q", delegate.Refused, ai.RefusedApproval)
|
||||
}
|
||||
return &ai.Response{
|
||||
Reply: "planned, called conformance_echo, and handled guarded delegate refusal",
|
||||
Answer: echo.Content + " " + delegate.Content,
|
||||
ToolCalls: []ai.ToolCall{
|
||||
{ID: "fake-plan-1", Name: "plan", Input: map[string]any{}},
|
||||
{ID: "fake-call-1", Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: echo.Content},
|
||||
{ID: "fake-delegate-1", Name: "delegate", Input: map[string]any{"task": "summarize the conformance marker", "to": "blocked-reviewer"}, Error: delegate.Content},
|
||||
},
|
||||
Reply: "used conformance_echo",
|
||||
Answer: res.Content,
|
||||
ToolCalls: []ai.ToolCall{{ID: "fake-call-1", Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: res.Content}},
|
||||
}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
@@ -113,23 +98,15 @@ func runAgentConformanceScenario(t *testing.T, provider conformanceProvider) {
|
||||
|
||||
var sawTool bool
|
||||
var sawRunInfo bool
|
||||
var sawBlockedDelegate bool
|
||||
agentOpts := []Option{
|
||||
Name("conformance-" + provider.name),
|
||||
Provider(provider.name),
|
||||
APIKey(os.Getenv(provider.key)),
|
||||
Prompt(conformanceSystemPrompt(provider.name)),
|
||||
Prompt("You are a conformance test agent. Use the conformance_echo tool exactly once with input {\"value\":\"agent-conformance\"}, then answer with the tool result."),
|
||||
WithRegistry(registry.NewMemoryRegistry()),
|
||||
WithStore(store.NewMemoryStore()),
|
||||
WithMemory(NewInMemory(8)),
|
||||
ModelCallTimeout(45 * time.Second),
|
||||
ApproveTool(func(tool string, input map[string]any) (bool, string) {
|
||||
if tool == "delegate" {
|
||||
sawBlockedDelegate = true
|
||||
return false, "cross-provider conformance blocks delegate side effects"
|
||||
}
|
||||
return true, ""
|
||||
}),
|
||||
WithTool("conformance_echo", "Echo a conformance value and return a deterministic marker.", map[string]any{
|
||||
"value": map[string]any{"type": "string", "description": "value to echo"},
|
||||
}, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
@@ -155,7 +132,7 @@ func runAgentConformanceScenario(t *testing.T, provider conformanceProvider) {
|
||||
}
|
||||
|
||||
a := New(agentOpts...)
|
||||
resp, err := askWithConformanceRetry(context.Background(), a, "Run the provider conformance check.", &sawTool, &sawBlockedDelegate)
|
||||
resp, err := a.Ask(context.Background(), "Run the provider conformance check.")
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
@@ -171,161 +148,11 @@ func runAgentConformanceScenario(t *testing.T, provider conformanceProvider) {
|
||||
if !sawRunInfo {
|
||||
t.Fatal("tool did not receive RunInfo")
|
||||
}
|
||||
if !sawBlockedDelegate {
|
||||
t.Fatal("provider did not exercise the guarded delegate path")
|
||||
}
|
||||
if !strings.Contains(resp.Reply, "agent-conformance-ok") && !strings.Contains(resp.Reply, "agent-conformance") {
|
||||
t.Fatalf("reply %q does not include conformance marker", resp.Reply)
|
||||
}
|
||||
}
|
||||
|
||||
func askWithConformanceRetry(ctx context.Context, a Agent, initialPrompt string, sawTool, sawBlockedDelegate *bool) (*Response, error) {
|
||||
const maxAttempts = 4
|
||||
prompt := initialPrompt
|
||||
var resp *Response
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
var err error
|
||||
resp, err = a.Ask(ctx, prompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sawRequiredTool := sawTool == nil || *sawTool
|
||||
sawRequiredDelegate := sawBlockedDelegate == nil || *sawBlockedDelegate
|
||||
hasMarker := responseHasConformanceMarker(resp)
|
||||
if sawRequiredTool && sawRequiredDelegate && hasMarker {
|
||||
return resp, nil
|
||||
}
|
||||
if attempt == maxAttempts {
|
||||
break
|
||||
}
|
||||
prompt = nextConformanceRetryPrompt(sawRequiredTool, sawRequiredDelegate, hasMarker, attempt+1)
|
||||
}
|
||||
missing := missingConformanceRequirements(sawTool, sawBlockedDelegate, responseHasConformanceMarker(resp))
|
||||
if len(missing) > 0 {
|
||||
return resp, fmt.Errorf("provider conformance incomplete after %d attempts: missing %s", maxAttempts, strings.Join(missing, ", "))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func askWithConformanceToolRetry(ctx context.Context, a Agent, initialPrompt string, sawTool *bool) (*Response, error) {
|
||||
return askWithConformanceRetry(ctx, a, initialPrompt, sawTool, nil)
|
||||
}
|
||||
|
||||
func missingConformanceRequirements(sawTool, sawBlockedDelegate *bool, hasMarker bool) []string {
|
||||
var missing []string
|
||||
if sawTool != nil && !*sawTool {
|
||||
missing = append(missing, "conformance_echo")
|
||||
}
|
||||
if sawBlockedDelegate != nil && !*sawBlockedDelegate {
|
||||
missing = append(missing, "guarded delegate")
|
||||
}
|
||||
if !hasMarker {
|
||||
missing = append(missing, "conformance marker")
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
const (
|
||||
conformanceEchoInputJSON = `{"value":"agent-conformance"}`
|
||||
conformanceDelegateInputJSON = `{"task":"summarize the conformance marker","to":"blocked-reviewer"}`
|
||||
conformanceDelegateTaggedCall = `<tool_call name="delegate">` + conformanceDelegateInputJSON + `</tool_call>`
|
||||
)
|
||||
|
||||
func conformanceSystemPrompt(provider string) string {
|
||||
prompt := "You are a conformance test agent. Create a short plan, use conformance_echo exactly once with input " + conformanceEchoInputJSON + ", then attempt to delegate a summary to blocked-reviewer with input " + conformanceDelegateInputJSON + ". You must complete both tool calls before any final answer; a final answer that only mentions the steps without calling both tools is invalid. If the delegate is refused, explain the refusal and answer with the echo result."
|
||||
if provider == "atlascloud" {
|
||||
prompt += " AtlasCloud/minimax conformance note: the delegate attempt is mandatory after conformance_echo. If native tool_calls are unavailable, emit the delegate as " + conformanceDelegateTaggedCall + " rather than answering in prose."
|
||||
}
|
||||
return prompt
|
||||
}
|
||||
|
||||
func TestAgentProviderConformanceAtlasCloudPromptRequiresTaggedDelegateFallback(t *testing.T) {
|
||||
prompt := conformanceSystemPrompt("atlascloud")
|
||||
for _, want := range []string{
|
||||
"delegate attempt is mandatory",
|
||||
"You must complete both tool calls before any final answer",
|
||||
"<tool_call name=\"delegate\">",
|
||||
`{"task":"summarize the conformance marker","to":"blocked-reviewer"}`,
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("atlascloud conformance prompt %q missing %q", prompt, want)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(conformanceSystemPrompt("openai"), "AtlasCloud/minimax") {
|
||||
t.Fatal("non-AtlasCloud prompt should not include provider-specific fallback guidance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentProviderConformanceRetryPromptsRequireBothTools(t *testing.T) {
|
||||
for name, prompt := range map[string]string{
|
||||
"missing tool": nextConformanceRetryPrompt(false, false, false, 2),
|
||||
"missing delegate": nextConformanceRetryPrompt(true, false, true, 2),
|
||||
} {
|
||||
for _, want := range []string{
|
||||
"delegate exactly once",
|
||||
conformanceDelegateTaggedCall,
|
||||
"do not",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("%s retry prompt %q missing %q", name, prompt, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentProviderConformanceFinalDelegateRetryUsesTaggedCall(t *testing.T) {
|
||||
prompt := nextConformanceRetryPrompt(true, false, true, 4)
|
||||
for _, want := range []string{
|
||||
"Final conformance retry",
|
||||
conformanceDelegateTaggedCall,
|
||||
"agent-conformance-ok",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("final delegate retry prompt %q missing %q", prompt, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func nextConformanceRetryPrompt(sawTool, sawBlockedDelegate, hasMarker bool, attempt int) string {
|
||||
if attempt >= 4 && sawTool && !sawBlockedDelegate {
|
||||
return "Final conformance retry: emit exactly this tagged tool call so the harness can execute the guarded delegate refusal, then include agent-conformance-ok and the refusal in the final answer: " + conformanceDelegateTaggedCall
|
||||
}
|
||||
switch {
|
||||
case !sawTool:
|
||||
return "The previous response did not call the required conformance_echo tool. Retry the same conformance check now: first call conformance_echo exactly once with input " + conformanceEchoInputJSON + ", then call delegate exactly once with input " + conformanceDelegateInputJSON + "; do not provide a final answer until both tool calls have been attempted. If native delegate tool_calls are unavailable after conformance_echo, emit exactly " + conformanceDelegateTaggedCall + ". The delegate is expected to be refused by policy; include that refusal and the agent-conformance marker in the final answer."
|
||||
case !sawBlockedDelegate:
|
||||
return "The previous response called conformance_echo but did not attempt the required guarded delegation. Continue the same conformance check now: call delegate exactly once with input " + conformanceDelegateInputJSON + "; do not answer in prose until that delegate call has been attempted. If native tool_calls are unavailable, emit exactly " + conformanceDelegateTaggedCall + ". The delegate is expected to be refused by policy; include that refusal and the agent-conformance marker in the final answer."
|
||||
case !hasMarker:
|
||||
return "The previous response completed the required tool calls but omitted the conformance marker. Continue the same conformance check now: do not call more tools; answer with the prior echo result marker agent-conformance-ok and mention the guarded delegate refusal."
|
||||
default:
|
||||
return "Retry the provider conformance check and include the agent-conformance marker in the final answer."
|
||||
}
|
||||
}
|
||||
|
||||
func responseHasConformanceMarker(resp *Response) bool {
|
||||
if resp == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(resp.Reply, "agent-conformance-ok") || strings.Contains(resp.Reply, "agent-conformance")
|
||||
}
|
||||
|
||||
func validateConformanceRequest(req *ai.Request, opts ai.Options) error {
|
||||
if req.Prompt == "" {
|
||||
return errors.New("missing prompt")
|
||||
}
|
||||
if len(req.Messages) == 0 || req.Messages[len(req.Messages)-1].Role != "user" {
|
||||
return fmt.Errorf("missing user history: %+v", req.Messages)
|
||||
}
|
||||
if len(req.Tools) == 0 {
|
||||
return errors.New("missing tools")
|
||||
}
|
||||
if opts.ToolHandler == nil {
|
||||
return errors.New("missing tool handler")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAgentProviderConformanceFakeError(t *testing.T) {
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
return nil, errors.New("conformance provider failure")
|
||||
@@ -345,231 +172,6 @@ func TestAgentProviderConformanceFakeError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentProviderConformanceRetriesMissingTool(t *testing.T) {
|
||||
var attempts int
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
attempts++
|
||||
if err := validateConformanceRequest(req, opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if attempts == 1 {
|
||||
return &ai.Response{Reply: "I can confirm agent-conformance in prose only."}, nil
|
||||
}
|
||||
echo := opts.ToolHandler(ctx, ai.ToolCall{
|
||||
ID: "fake-call-1",
|
||||
Name: "conformance_echo",
|
||||
Input: map[string]any{"value": "agent-conformance"},
|
||||
})
|
||||
return &ai.Response{
|
||||
Reply: "called conformance_echo",
|
||||
Answer: echo.Content,
|
||||
ToolCalls: []ai.ToolCall{
|
||||
{ID: "fake-call-1", Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: echo.Content},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
var sawTool bool
|
||||
a := New(
|
||||
Name("conformance-retry"),
|
||||
Provider("fake"),
|
||||
WithRegistry(registry.NewMemoryRegistry()),
|
||||
WithStore(store.NewMemoryStore()),
|
||||
WithMemory(NewInMemory(4)),
|
||||
WithTool("conformance_echo", "Echo a conformance value.", map[string]any{
|
||||
"value": map[string]any{"type": "string"},
|
||||
}, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
sawTool = true
|
||||
return `{"marker":"agent-conformance-ok"}`, nil
|
||||
}),
|
||||
)
|
||||
|
||||
resp, err := askWithConformanceToolRetry(context.Background(), a, "Run the provider conformance check.", &sawTool)
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts = %d, want retry after missing tool", attempts)
|
||||
}
|
||||
if !sawTool {
|
||||
t.Fatal("retry did not execute conformance_echo")
|
||||
}
|
||||
if !strings.Contains(resp.Reply, "agent-conformance-ok") {
|
||||
t.Fatalf("Reply = %q, want tool result marker", resp.Reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentProviderConformanceRetriesMissingMarker(t *testing.T) {
|
||||
var attempts int
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
attempts++
|
||||
if err := validateConformanceRequest(req, opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if attempts == 1 {
|
||||
return &ai.Response{Reply: "called conformance_echo and handled guarded delegate refusal without the required marker"}, nil
|
||||
}
|
||||
return &ai.Response{Reply: "agent-conformance-ok after guarded delegate refusal"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
sawTool := true
|
||||
sawBlockedDelegate := true
|
||||
a := New(
|
||||
Name("conformance-retry-marker"),
|
||||
Provider("fake"),
|
||||
WithRegistry(registry.NewMemoryRegistry()),
|
||||
WithStore(store.NewMemoryStore()),
|
||||
WithMemory(NewInMemory(4)),
|
||||
WithTool("conformance_echo", "Echo a conformance value.", map[string]any{
|
||||
"value": map[string]any{"type": "string"},
|
||||
}, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
return `{"marker":"agent-conformance-ok"}`, nil
|
||||
}),
|
||||
)
|
||||
|
||||
resp, err := askWithConformanceRetry(context.Background(), a, "Run the provider conformance check.", &sawTool, &sawBlockedDelegate)
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts = %d, want retry after missing marker", attempts)
|
||||
}
|
||||
if !strings.Contains(resp.Reply, "agent-conformance-ok") {
|
||||
t.Fatalf("Reply = %q, want conformance marker", resp.Reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentProviderConformanceRetriesMissingDelegate(t *testing.T) {
|
||||
var attempts int
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
attempts++
|
||||
if err := validateConformanceRequest(req, opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
echo := opts.ToolHandler(ctx, ai.ToolCall{
|
||||
ID: "fake-call-1",
|
||||
Name: "conformance_echo",
|
||||
Input: map[string]any{"value": "agent-conformance"},
|
||||
})
|
||||
if attempts == 1 {
|
||||
return &ai.Response{
|
||||
Reply: "called conformance_echo but skipped delegate",
|
||||
Answer: echo.Content,
|
||||
ToolCalls: []ai.ToolCall{
|
||||
{ID: "fake-call-1", Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: echo.Content},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
delegate := opts.ToolHandler(ctx, ai.ToolCall{
|
||||
ID: "fake-delegate-1",
|
||||
Name: "delegate",
|
||||
Input: map[string]any{"task": "summarize the conformance marker", "to": "blocked-reviewer"},
|
||||
})
|
||||
return &ai.Response{
|
||||
Reply: "called conformance_echo and handled guarded delegate refusal",
|
||||
Answer: echo.Content + " " + delegate.Content,
|
||||
ToolCalls: []ai.ToolCall{
|
||||
{ID: "fake-call-1", Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: echo.Content},
|
||||
{ID: "fake-delegate-1", Name: "delegate", Input: map[string]any{"task": "summarize the conformance marker", "to": "blocked-reviewer"}, Error: delegate.Content},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
var sawTool bool
|
||||
var sawBlockedDelegate bool
|
||||
a := New(
|
||||
Name("conformance-retry-delegate"),
|
||||
Provider("fake"),
|
||||
WithRegistry(registry.NewMemoryRegistry()),
|
||||
WithStore(store.NewMemoryStore()),
|
||||
WithMemory(NewInMemory(4)),
|
||||
ApproveTool(func(tool string, input map[string]any) (bool, string) {
|
||||
if tool == "delegate" {
|
||||
sawBlockedDelegate = true
|
||||
return false, "cross-provider conformance blocks delegate side effects"
|
||||
}
|
||||
return true, ""
|
||||
}),
|
||||
WithTool("conformance_echo", "Echo a conformance value.", map[string]any{
|
||||
"value": map[string]any{"type": "string"},
|
||||
}, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
sawTool = true
|
||||
return `{"marker":"agent-conformance-ok"}`, nil
|
||||
}),
|
||||
)
|
||||
|
||||
resp, err := askWithConformanceRetry(context.Background(), a, "Run the provider conformance check.", &sawTool, &sawBlockedDelegate)
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts = %d, want retry after missing delegate", attempts)
|
||||
}
|
||||
if !sawBlockedDelegate {
|
||||
t.Fatal("retry did not attempt guarded delegate")
|
||||
}
|
||||
if !strings.Contains(resp.Reply, "agent-conformance-ok") && !strings.Contains(resp.Reply, "agent-conformance") {
|
||||
t.Fatalf("Reply = %q, want conformance marker", resp.Reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentProviderConformanceFailsWhenDelegateStillMissing(t *testing.T) {
|
||||
var attempts int
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
attempts++
|
||||
if err := validateConformanceRequest(req, opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
echo := opts.ToolHandler(ctx, ai.ToolCall{
|
||||
ID: fmt.Sprintf("fake-call-%d", attempts),
|
||||
Name: "conformance_echo",
|
||||
Input: map[string]any{"value": "agent-conformance"},
|
||||
})
|
||||
return &ai.Response{
|
||||
Reply: "called conformance_echo with agent-conformance-ok but skipped delegate",
|
||||
Answer: echo.Content,
|
||||
ToolCalls: []ai.ToolCall{
|
||||
{ID: fmt.Sprintf("fake-call-%d", attempts), Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: echo.Content},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
var sawTool bool
|
||||
var sawBlockedDelegate bool
|
||||
a := New(
|
||||
Name("conformance-retry-delegate-exhausted"),
|
||||
Provider("fake"),
|
||||
WithRegistry(registry.NewMemoryRegistry()),
|
||||
WithStore(store.NewMemoryStore()),
|
||||
WithMemory(NewInMemory(4)),
|
||||
ApproveTool(func(tool string, input map[string]any) (bool, string) {
|
||||
if tool == "delegate" {
|
||||
sawBlockedDelegate = true
|
||||
return false, "cross-provider conformance blocks delegate side effects"
|
||||
}
|
||||
return true, ""
|
||||
}),
|
||||
WithTool("conformance_echo", "Echo a conformance value.", map[string]any{
|
||||
"value": map[string]any{"type": "string"},
|
||||
}, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
sawTool = true
|
||||
return `{"marker":"agent-conformance-ok"}`, nil
|
||||
}),
|
||||
)
|
||||
|
||||
_, err := askWithConformanceRetry(context.Background(), a, "Run the provider conformance check.", &sawTool, &sawBlockedDelegate)
|
||||
if err == nil || !strings.Contains(err.Error(), "guarded delegate") {
|
||||
t.Fatalf("Ask error = %v, want missing guarded delegate", err)
|
||||
}
|
||||
if attempts != 4 {
|
||||
t.Fatalf("attempts = %d, want retries through max attempts", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentExecutesProviderTextToolCallFallback(t *testing.T) {
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
if opts.ToolHandler == nil {
|
||||
@@ -616,67 +218,3 @@ func TestAgentExecutesProviderTextToolCallFallback(t *testing.T) {
|
||||
t.Fatalf("Reply = %q, want tool result instead of raw JSON", resp.Reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentExecutesTextToolCallFallbackAfterStructuredToolCall(t *testing.T) {
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
if opts.ToolHandler == nil {
|
||||
return nil, errors.New("missing tool handler")
|
||||
}
|
||||
echo := opts.ToolHandler(ctx, ai.ToolCall{
|
||||
ID: "structured-echo-1",
|
||||
Name: "conformance_echo",
|
||||
Input: map[string]any{"value": "agent-conformance"},
|
||||
})
|
||||
return &ai.Response{
|
||||
Reply: echo.Content + "\n<tool_call name=\"delegate\">{\"task\":\"summarize the conformance marker\",\"to\":\"blocked-reviewer\"}</tool_call>",
|
||||
Answer: echo.Content,
|
||||
ToolCalls: []ai.ToolCall{
|
||||
{ID: "structured-echo-1", Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: echo.Content},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
var sawTool bool
|
||||
var sawBlockedDelegate bool
|
||||
a := New(
|
||||
Name("conformance-mixed-text-tool"),
|
||||
Provider("fake"),
|
||||
WithRegistry(registry.NewMemoryRegistry()),
|
||||
WithStore(store.NewMemoryStore()),
|
||||
WithMemory(NewInMemory(4)),
|
||||
ApproveTool(func(tool string, input map[string]any) (bool, string) {
|
||||
if tool == "delegate" {
|
||||
sawBlockedDelegate = true
|
||||
return false, "cross-provider conformance blocks delegate side effects"
|
||||
}
|
||||
return true, ""
|
||||
}),
|
||||
WithTool("conformance_echo", "Echo a conformance value.", map[string]any{
|
||||
"value": map[string]any{"type": "string"},
|
||||
}, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
sawTool = true
|
||||
return `{"marker":"agent-conformance-ok"}`, nil
|
||||
}),
|
||||
)
|
||||
|
||||
resp, err := a.Ask(context.Background(), "Run the mixed structured/text tool fallback.")
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if !sawTool {
|
||||
t.Fatal("structured conformance_echo did not execute")
|
||||
}
|
||||
if !sawBlockedDelegate {
|
||||
t.Fatal("tagged text delegate fallback did not execute")
|
||||
}
|
||||
if len(resp.ToolCalls) != 2 {
|
||||
t.Fatalf("ToolCalls = %+v, want structured echo and text delegate", resp.ToolCalls)
|
||||
}
|
||||
if resp.ToolCalls[1].Name != "delegate" || resp.ToolCalls[1].Error != ai.RefusedApproval {
|
||||
t.Fatalf("delegate ToolCall = %+v, want refused delegate", resp.ToolCalls[1])
|
||||
}
|
||||
if !strings.Contains(resp.Reply, "agent-conformance-ok") {
|
||||
t.Fatalf("Reply = %q, want conformance marker", resp.Reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/broker"
|
||||
"go-micro.dev/v6/client"
|
||||
"go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/registry"
|
||||
@@ -45,7 +44,6 @@ type Options struct {
|
||||
Address string
|
||||
Registry registry.Registry
|
||||
Client client.Client
|
||||
Broker broker.Broker
|
||||
Store store.Store
|
||||
HistoryLimit int
|
||||
|
||||
@@ -194,13 +192,6 @@ func WithClient(c client.Client) Option {
|
||||
return func(o *Options) { o.Client = c }
|
||||
}
|
||||
|
||||
// WithBroker sets the broker used by the agent service endpoint. Use an
|
||||
// in-memory broker in local harnesses/tests to avoid sharing the package-wide
|
||||
// default broker listener across concurrently running examples.
|
||||
func WithBroker(b broker.Broker) Option {
|
||||
return func(o *Options) { o.Broker = b }
|
||||
}
|
||||
|
||||
// WithStore sets the store for agent memory.
|
||||
func WithStore(s store.Store) Option {
|
||||
return func(o *Options) { o.Store = s }
|
||||
|
||||
+6
-34
@@ -36,8 +36,6 @@ const (
|
||||
AttrTotalTokens = "agent.tokens.total"
|
||||
AttrAttempt = "agent.model.attempt"
|
||||
AttrMaxAttempts = "agent.model.max_attempts"
|
||||
AttrToolAttempt = "agent.tool.attempt"
|
||||
AttrToolMaxAttempts = "agent.tool.max_attempts"
|
||||
AttrToolName = "agent.tool.name"
|
||||
AttrDelegate = "agent.delegate"
|
||||
AttrGuardrailBlock = "agent.guardrail.block"
|
||||
@@ -105,8 +103,6 @@ type RunSummary struct {
|
||||
DurationMS int64 `json:"duration_ms,omitempty"`
|
||||
Events int `json:"events"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Checkpoint string `json:"checkpoint,omitempty"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
LastKind string `json:"last_kind,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastErrorKind string `json:"last_error_kind,omitempty"`
|
||||
@@ -217,13 +213,13 @@ func (m *tracedModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
}
|
||||
span.End()
|
||||
e := RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "model", Provider: provider, Model: model, Attempt: info.Attempt, MaxAttempts: info.MaxAttempts, LatencyMS: dur, Tokens: usage}
|
||||
if err != nil {
|
||||
e.Error = err.Error()
|
||||
e.ErrorKind = string(ai.ClassifyError(err))
|
||||
}
|
||||
m.a.recordSpanEvent(span, e)
|
||||
span.End()
|
||||
return resp, err
|
||||
}
|
||||
|
||||
@@ -366,11 +362,7 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
|
||||
res := next(ctx, call)
|
||||
dur := time.Since(start).Milliseconds()
|
||||
resErr := resultError(res)
|
||||
toolAttempts := res.Attempts
|
||||
if toolAttempts <= 0 {
|
||||
toolAttempts = 1
|
||||
}
|
||||
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, Attempt: toolAttempts, MaxAttempts: a.opts.ToolMaxAttempts, LatencyMS: dur, Refused: res.Refused, Error: resErr, ErrorKind: classifyToolError(resErr)})
|
||||
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, LatencyMS: dur, Refused: res.Refused, Error: resErr, ErrorKind: classifyToolError(resErr)})
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -384,14 +376,6 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
|
||||
res := next(ctx, call)
|
||||
dur := time.Since(start).Milliseconds()
|
||||
attrs := []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
|
||||
toolAttempts := res.Attempts
|
||||
if toolAttempts <= 0 {
|
||||
toolAttempts = 1
|
||||
}
|
||||
attrs = append(attrs, attribute.Int(AttrToolAttempt, toolAttempts))
|
||||
if a.opts.ToolMaxAttempts > 0 {
|
||||
attrs = append(attrs, attribute.Int(AttrToolMaxAttempts, a.opts.ToolMaxAttempts))
|
||||
}
|
||||
if res.Refused != "" {
|
||||
attrs = append(attrs, attribute.Bool(AttrGuardrailBlock, true), attribute.String(AttrRefusal, res.Refused))
|
||||
}
|
||||
@@ -407,8 +391,8 @@ 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)})
|
||||
span.End()
|
||||
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, LatencyMS: dur, Refused: res.Refused, Error: resErr, ErrorKind: classifyToolError(resErr)})
|
||||
return res
|
||||
}
|
||||
}
|
||||
@@ -475,18 +459,10 @@ func runEventAttributes(e RunEvent) []attribute.KeyValue {
|
||||
attrs = append(attrs, attribute.String(AttrModel, e.Model))
|
||||
}
|
||||
if e.Attempt > 0 {
|
||||
if e.Kind == "tool" {
|
||||
attrs = append(attrs, attribute.Int(AttrToolAttempt, e.Attempt))
|
||||
} else {
|
||||
attrs = append(attrs, attribute.Int(AttrAttempt, e.Attempt))
|
||||
}
|
||||
attrs = append(attrs, attribute.Int(AttrAttempt, e.Attempt))
|
||||
}
|
||||
if e.MaxAttempts > 0 {
|
||||
if e.Kind == "tool" {
|
||||
attrs = append(attrs, attribute.Int(AttrToolMaxAttempts, e.MaxAttempts))
|
||||
} else {
|
||||
attrs = append(attrs, attribute.Int(AttrMaxAttempts, e.MaxAttempts))
|
||||
}
|
||||
attrs = append(attrs, attribute.Int(AttrMaxAttempts, e.MaxAttempts))
|
||||
}
|
||||
if e.LatencyMS > 0 {
|
||||
attrs = append(attrs, attribute.Int64(AttrLatencyMS, e.LatencyMS))
|
||||
@@ -604,10 +580,6 @@ func ListRunSummariesWithOptions(s store.Store, agentName string, opts RunListOp
|
||||
if e.SpanID != "" {
|
||||
summary.SpanID = e.SpanID
|
||||
}
|
||||
if e.Kind == "checkpoint" {
|
||||
summary.Checkpoint = e.Status
|
||||
summary.Stage = e.Name
|
||||
}
|
||||
if e.Error != "" {
|
||||
summary.LastError = e.Error
|
||||
}
|
||||
@@ -646,7 +618,7 @@ func runStatus(events []RunEvent) string {
|
||||
if e.Error != "" || e.Kind == "error" {
|
||||
status = runErrorStatus(e.ErrorKind)
|
||||
}
|
||||
if e.Kind == "done" {
|
||||
if e.Kind == "done" && status == "running" {
|
||||
status = "done"
|
||||
}
|
||||
}
|
||||
|
||||
+4
-92
@@ -95,16 +95,8 @@ func TestAgentOpenTelemetrySpans(t *testing.T) {
|
||||
if attrs[AttrRunID] != runID || attrs[AttrAgentName] != "runner" {
|
||||
t.Fatalf("%s missing run correlation attributes: %#v", s.Name(), attrs)
|
||||
}
|
||||
if s.Name() == spanNameModelCall {
|
||||
if attrs[AttrAttempt] != "1" || attrs[AttrMaxAttempts] != "1" {
|
||||
t.Fatalf("model span missing attempt attributes: %#v", attrs)
|
||||
}
|
||||
if !spanEventHasRunInfo(s.Events(), "agent.model", runID, "runner") {
|
||||
t.Fatalf("model span missing model event: %#v", s.Events())
|
||||
}
|
||||
}
|
||||
if s.Name() == spanNameToolCall && !spanEventHasRunInfo(s.Events(), "agent.tool", runID, "runner") {
|
||||
t.Fatalf("tool span missing tool event: %#v", s.Events())
|
||||
if s.Name() == spanNameModelCall && (attrs[AttrAttempt] != "1" || attrs[AttrMaxAttempts] != "1") {
|
||||
t.Fatalf("model span missing attempt attributes: %#v", attrs)
|
||||
}
|
||||
}
|
||||
keys, err := store.Scope(st, "agent", "runner").List(store.ListPrefix("runs/"))
|
||||
@@ -142,82 +134,6 @@ func TestAgentOpenTelemetrySpans(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentOpenTelemetryToolRetryAttempts(t *testing.T) {
|
||||
exp := tracetest.NewInMemoryExporter()
|
||||
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
|
||||
st := store.NewMemoryStore()
|
||||
calls := 0
|
||||
a := New(
|
||||
Name("tool-retry-otel"),
|
||||
Provider("oteltest"),
|
||||
WithStore(st),
|
||||
TraceProvider(tp),
|
||||
ToolRetry(3, time.Millisecond),
|
||||
WithTool("probe", "probe", nil, func(context.Context, map[string]any) (string, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return "", errors.New("rate limit exceeded")
|
||||
}
|
||||
return "ok", nil
|
||||
}),
|
||||
)
|
||||
if _, err := a.Ask(context.Background(), "hello"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("tool calls = %d, want retry success after 2 attempts", calls)
|
||||
}
|
||||
|
||||
var sawToolSpan bool
|
||||
for _, span := range exp.GetSpans().Snapshots() {
|
||||
if span.Name() != spanNameToolCall {
|
||||
continue
|
||||
}
|
||||
attrs := spanAttributes(span.Attributes())
|
||||
if attrs[AttrToolName] != "probe" {
|
||||
continue
|
||||
}
|
||||
if attrs[AttrToolAttempt] != "2" || attrs[AttrToolMaxAttempts] != "3" {
|
||||
t.Fatalf("tool retry span attempts = %#v", attrs)
|
||||
}
|
||||
if !spanEventHasAttr(span.Events(), "agent.tool", AttrToolAttempt, "2") || !spanEventHasAttr(span.Events(), "agent.tool", AttrToolMaxAttempts, "3") {
|
||||
t.Fatalf("tool retry event missing attempt attributes: %#v", span.Events())
|
||||
}
|
||||
sawToolSpan = true
|
||||
}
|
||||
if !sawToolSpan {
|
||||
t.Fatal("tool retry span not emitted")
|
||||
}
|
||||
|
||||
summaries, err := ListRunSummaries(st, "tool-retry-otel")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events, err := LoadRunEvents(st, "tool-retry-otel", summaries[0].RunID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, event := range events {
|
||||
if event.Kind == "tool" && event.Name == "probe" && event.Attempt == 2 && event.MaxAttempts == 3 {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("persisted tool event missing retry attempts: %#v", events)
|
||||
}
|
||||
|
||||
func spanEventHasAttr(events []trace.Event, name, key, value string) bool {
|
||||
for _, event := range events {
|
||||
if event.Name != name {
|
||||
continue
|
||||
}
|
||||
attrs := spanAttributes(event.Attributes)
|
||||
if attrs[key] == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAgentRunObservabilityRedactsInputByDefault(t *testing.T) {
|
||||
secret := "deploy production with token sk-secret"
|
||||
exp := tracetest.NewInMemoryExporter()
|
||||
@@ -584,8 +500,7 @@ func TestListRunSummaries(t *testing.T) {
|
||||
{Time: time.Unix(0, 1), RunID: "run-a", Agent: "runner", TraceID: "trace-a", SpanID: "span-a", Kind: "run", Name: "first"},
|
||||
{Time: time.Unix(0, 2), RunID: "run-a", Agent: "runner", Kind: "tool", Name: "probe"},
|
||||
{Time: time.Unix(0, 3), RunID: "run-b", Agent: "runner", ParentID: "parent", Kind: "run", Name: "second"},
|
||||
{Time: time.Unix(0, 4), RunID: "run-b", Agent: "runner", ParentID: "parent", Kind: "checkpoint", Name: "ask", Status: "failed"},
|
||||
{Time: time.Unix(0, 5), RunID: "run-b", Agent: "runner", ParentID: "parent", Kind: "error", Error: "context deadline exceeded", ErrorKind: string(ai.ErrorKindTimeout)},
|
||||
{Time: time.Unix(0, 4), RunID: "run-b", Agent: "runner", ParentID: "parent", Kind: "error", Error: "context deadline exceeded", ErrorKind: string(ai.ErrorKindTimeout)},
|
||||
}
|
||||
for _, e := range events {
|
||||
b, err := json.Marshal(e)
|
||||
@@ -608,7 +523,7 @@ func TestListRunSummaries(t *testing.T) {
|
||||
if got[0].RunID != "run-a" || got[0].TraceID != "trace-a" || got[0].SpanID != "span-a" || got[0].Events != 2 || got[0].Status != "running" || got[0].DurationMS != 0 || got[0].LastKind != "tool" || !got[0].UpdatedAt.Equal(time.Unix(0, 2)) {
|
||||
t.Fatalf("unexpected run-a summary: %#v", got[0])
|
||||
}
|
||||
if got[1].RunID != "run-b" || got[1].ParentID != "parent" || got[1].Events != 3 || got[1].Status != "timeout" || got[1].DurationMS != 0 || got[1].LastKind != "error" || got[1].Checkpoint != "failed" || got[1].Stage != "ask" || got[1].LastError != "context deadline exceeded" || got[1].LastErrorKind != string(ai.ErrorKindTimeout) {
|
||||
if got[1].RunID != "run-b" || got[1].ParentID != "parent" || got[1].Events != 2 || got[1].Status != "timeout" || got[1].DurationMS != 0 || got[1].LastKind != "error" || got[1].LastError != "context deadline exceeded" || got[1].LastErrorKind != string(ai.ErrorKindTimeout) {
|
||||
t.Fatalf("unexpected run-b summary: %#v", got[1])
|
||||
}
|
||||
}
|
||||
@@ -739,9 +654,6 @@ func TestAgentOpenTelemetrySpansModelStream(t *testing.T) {
|
||||
if attrs[AttrAttempt] != "2" || attrs[AttrMaxAttempts] != "3" || attrs[AttrTotalTokens] != "5" {
|
||||
t.Fatalf("stream span missing attempt/usage attributes: %#v", attrs)
|
||||
}
|
||||
if !spanEventHasRunInfo(s.Events(), "agent.stream", "stream-run-1", "stream-runner") {
|
||||
t.Fatalf("stream span missing stream event: %#v", s.Events())
|
||||
}
|
||||
sawStream = true
|
||||
}
|
||||
if !sawStream {
|
||||
|
||||
@@ -77,86 +77,6 @@ func TestAskRetriesTransientErrorsThenSurfacesStructuredError(t *testing.T) {
|
||||
if attempts != 2 {
|
||||
t.Fatalf("model attempts = %d, want 2", attempts)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "micro inspect agent <name> --status timeout") ||
|
||||
!strings.Contains(err.Error(), "docs/guides/debugging-agents.md") {
|
||||
t.Fatalf("Ask error = %q, want actionable timeout/debugging guidance", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelRetryDoesNotDuplicateCheckpointedToolSideEffects(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "retry-tool-dedupe-agent")
|
||||
attempts := 0
|
||||
toolRuns := 0
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
attempts++
|
||||
if opts.ToolHandler == nil {
|
||||
t.Fatal("missing tool handler")
|
||||
}
|
||||
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "create-1", Name: "external.create", Input: map[string]any{"title": "Retry safe"}})
|
||||
if res.Content != "created Retry safe" {
|
||||
t.Fatalf("tool result = %q, want cached create result", res.Content)
|
||||
}
|
||||
if attempts == 1 {
|
||||
return nil, testStatusError{code: 503}
|
||||
}
|
||||
return &ai.Response{Reply: "done", ToolCalls: []ai.ToolCall{{ID: "create-1", Name: "external.create", Input: map[string]any{"title": "Retry safe"}, Result: res.Content}}}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(
|
||||
Name("retry-tool-dedupe-agent"),
|
||||
WithCheckpoint(cp),
|
||||
ModelRetry(2, time.Millisecond),
|
||||
WithTool("external.create", "create once", nil, func(context.Context, map[string]any) (string, error) {
|
||||
toolRuns++
|
||||
return "created Retry safe", nil
|
||||
}),
|
||||
)
|
||||
|
||||
resp, err := a.Ask(ctx, "create once despite a transient provider retry")
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if resp.Reply != "done" {
|
||||
t.Fatalf("reply = %q, want done", resp.Reply)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("model attempts = %d, want retry after transient provider failure", attempts)
|
||||
}
|
||||
if toolRuns != 1 {
|
||||
t.Fatalf("tool executions = %d, want checkpointed side effect reused across retry", toolRuns)
|
||||
}
|
||||
runs, err := cp.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("checkpointed runs = %d, want 1", len(runs))
|
||||
}
|
||||
if _, ok := findStep(runs[0].Steps, `tool:external.create:{"title":"Retry safe"}`); !ok {
|
||||
t.Fatalf("checkpoint steps = %#v, want completed external.create step", runs[0].Steps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAskRateLimitFailureSuggestsPreflightAndInspect(t *testing.T) {
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
return nil, testStatusError{code: 429}
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("rate-limit-guidance"), ModelRetry(1, time.Millisecond))
|
||||
_, err := a.Ask(context.Background(), "hello")
|
||||
if err == nil {
|
||||
t.Fatal("Ask succeeded, want rate-limit failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "micro inspect agent <name> --status rate_limited") ||
|
||||
!strings.Contains(err.Error(), "micro agent preflight") {
|
||||
t.Fatalf("Ask error = %q, want inspect and preflight guidance", err.Error())
|
||||
}
|
||||
if ai.ClassifyError(err) != ai.ErrorKindRateLimited {
|
||||
t.Fatalf("ClassifyError(wrapped error) = %q, want rate_limited", ai.ClassifyError(err))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanceledAskContextSkipsToolExecution(t *testing.T) {
|
||||
|
||||
+4
-10
@@ -71,15 +71,14 @@ func ResumeStreamAsk(ctx context.Context, ag Agent, runID string) (AgentStream,
|
||||
// StreamAsk runs tools like Ask, emits ToolStart/ToolEnd events as they execute,
|
||||
// then emits chunks of the final answer followed by a Done event.
|
||||
func (a *agentImpl) StreamAsk(ctx context.Context, message string) (AgentStream, error) {
|
||||
streamCtx, cancel := context.WithCancel(ctx)
|
||||
events := make(chan *StreamEvent, 16)
|
||||
done := make(chan struct{})
|
||||
s := &agentStream{events: events, done: done, cancel: cancel}
|
||||
s := &agentStream{events: events, done: done}
|
||||
|
||||
go func() {
|
||||
defer close(events)
|
||||
defer close(done)
|
||||
resp, err := a.askWithStreamEvents(streamCtx, message, events)
|
||||
resp, err := a.askWithStreamEvents(ctx, message, events)
|
||||
if err != nil {
|
||||
s.setErr(err)
|
||||
return
|
||||
@@ -95,15 +94,14 @@ func (a *agentImpl) StreamAsk(ctx context.Context, message string) (AgentStream,
|
||||
}
|
||||
|
||||
func (a *agentImpl) resumeStreamAsk(ctx context.Context, runID string) (AgentStream, error) {
|
||||
streamCtx, cancel := context.WithCancel(ctx)
|
||||
events := make(chan *StreamEvent, 16)
|
||||
done := make(chan struct{})
|
||||
s := &agentStream{events: events, done: done, cancel: cancel}
|
||||
s := &agentStream{events: events, done: done}
|
||||
|
||||
go func() {
|
||||
defer close(events)
|
||||
defer close(done)
|
||||
resp, err := a.resumeWithStreamEvents(streamCtx, runID, events)
|
||||
resp, err := a.resumeWithStreamEvents(ctx, runID, events)
|
||||
if err != nil {
|
||||
s.setErr(err)
|
||||
return
|
||||
@@ -262,7 +260,6 @@ func (a *agentImpl) streamAskAI(ctx context.Context, message string) (ai.Stream,
|
||||
type agentStream struct {
|
||||
events <-chan *StreamEvent
|
||||
done <-chan struct{}
|
||||
cancel context.CancelFunc
|
||||
mu sync.Mutex
|
||||
err error
|
||||
}
|
||||
@@ -281,9 +278,6 @@ func (s *agentStream) Recv() (*StreamEvent, error) {
|
||||
}
|
||||
|
||||
func (s *agentStream) Close() error {
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
<-s.done
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/flow"
|
||||
@@ -76,39 +75,6 @@ func TestStreamAskEmitsToolEventsAndFinalTokens(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamAskCloseCancelsInFlightModelCall(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("stream-cancel"))
|
||||
stream, err := a.StreamAsk(context.Background(), "cancel me")
|
||||
if err != nil {
|
||||
t.Fatalf("StreamAsk: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("model call did not start")
|
||||
}
|
||||
|
||||
closed := make(chan error, 1)
|
||||
go func() { closed <- stream.Close() }()
|
||||
select {
|
||||
case err := <-closed:
|
||||
if err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Close did not cancel the in-flight stream")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamAskHelperRejectsUnsupportedAgent(t *testing.T) {
|
||||
_, err := StreamAsk(context.Background(), unsupportedAgent{}, "hello")
|
||||
if err == nil {
|
||||
|
||||
+20
-167
@@ -11,16 +11,13 @@ import (
|
||||
)
|
||||
|
||||
var fencedJSONBlock = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
|
||||
var taggedToolCallBlock = regexp.MustCompile(`(?s)<[^<>]*(?:tool_call|tool_calls|function=)[^<>]*>(.*?)</[^<>]*>`)
|
||||
var singleTaggedToolCall = regexp.MustCompile(`(?s)<(tool_call\b[^<>]*|[^<>]*function=[^<>]*)>(.*?)</[^<>]*>`)
|
||||
|
||||
type textToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Tool string `json:"tool"`
|
||||
Input map[string]any `json:"input"`
|
||||
Arguments any `json:"arguments"`
|
||||
Function *textToolCall `json:"function"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
|
||||
// executeTextToolCalls is a compatibility fallback for providers that return a
|
||||
@@ -48,57 +45,18 @@ func (a *agentImpl) executeTextToolCalls(ctx context.Context, reply string, tool
|
||||
return calls, strings.Join(results, "\n"), true
|
||||
}
|
||||
|
||||
// executeAdditionalTextToolCalls runs text-encoded tool calls that accompany a
|
||||
// structured tool_calls response. Some OpenAI-compatible providers can mix the
|
||||
// two forms in a single assistant turn: for example, emitting a native
|
||||
// conformance_echo call while rendering a follow-up guarded delegate call as
|
||||
// <tool_call name="delegate">...</tool_call> text. Keep this fallback additive
|
||||
// and de-duplicate calls already represented in the structured tool_calls list.
|
||||
func (a *agentImpl) executeAdditionalTextToolCalls(ctx context.Context, reply string, tools []ai.Tool, existing []ai.ToolCall) ([]ai.ToolCall, string, bool) {
|
||||
calls := parseTextToolCalls(reply, tools)
|
||||
if len(calls) == 0 {
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, call := range existing {
|
||||
seen[textToolCallKey(call)] = true
|
||||
}
|
||||
|
||||
handler := a.toolHandler()
|
||||
out := make([]ai.ToolCall, 0, len(calls))
|
||||
results := make([]string, 0, len(calls))
|
||||
for i := range calls {
|
||||
if seen[textToolCallKey(calls[i])] {
|
||||
continue
|
||||
}
|
||||
result := handler(ctx, calls[i])
|
||||
calls[i].Result = result.Content
|
||||
if result.Refused != "" {
|
||||
calls[i].Error = result.Refused
|
||||
}
|
||||
if result.Content != "" {
|
||||
results = append(results, result.Content)
|
||||
}
|
||||
out = append(out, calls[i])
|
||||
}
|
||||
return out, strings.Join(results, "\n"), len(out) > 0
|
||||
}
|
||||
|
||||
func textToolCallKey(call ai.ToolCall) string {
|
||||
b, _ := json.Marshal(call.Input)
|
||||
return call.Name + "\x00" + string(b)
|
||||
}
|
||||
|
||||
func parseTextToolCalls(text string, tools []ai.Tool) []ai.ToolCall {
|
||||
allowed := textToolNames(tools)
|
||||
allowed := map[string]bool{}
|
||||
for _, tool := range tools {
|
||||
allowed[tool.Name] = true
|
||||
if tool.OriginalName != "" {
|
||||
allowed[tool.OriginalName] = true
|
||||
}
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if calls := decodeTaggedTextToolCalls(text, allowed); len(calls) > 0 {
|
||||
return calls
|
||||
}
|
||||
for _, candidate := range jsonCandidates(text) {
|
||||
if calls := decodeTextToolCalls(candidate, allowed); len(calls) > 0 {
|
||||
return calls
|
||||
@@ -107,33 +65,6 @@ func parseTextToolCalls(text string, tools []ai.Tool) []ai.ToolCall {
|
||||
return nil
|
||||
}
|
||||
|
||||
func textToolNames(tools []ai.Tool) map[string]string {
|
||||
allowed := map[string]string{}
|
||||
for _, tool := range tools {
|
||||
addTextToolName(allowed, tool.Name, tool.Name)
|
||||
if tool.OriginalName != "" {
|
||||
addTextToolName(allowed, tool.OriginalName, tool.Name)
|
||||
}
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
func addTextToolName(allowed map[string]string, name, canonical string) {
|
||||
if name == "" || canonical == "" {
|
||||
return
|
||||
}
|
||||
allowed[name] = canonical
|
||||
// Some OpenAI-compatible models describe an idempotent Add endpoint as a
|
||||
// creation action and emit the otherwise-correct service tool with a Create
|
||||
// suffix in text-only tool-call markup. Keep the fallback bounded by the
|
||||
// offered service tool prefix so ordinary unknown tools remain ignored.
|
||||
for _, suffix := range []string{"_Add", ".Add"} {
|
||||
if strings.HasSuffix(name, suffix) {
|
||||
allowed[strings.TrimSuffix(name, suffix)+strings.Replace(suffix, "Add", "Create", 1)] = canonical
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func jsonCandidates(text string) []string {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
var out []string
|
||||
@@ -145,18 +76,13 @@ func jsonCandidates(text string) []string {
|
||||
out = append(out, strings.TrimSpace(match[1]))
|
||||
}
|
||||
}
|
||||
for _, match := range taggedToolCallBlock.FindAllStringSubmatch(text, -1) {
|
||||
if len(match) > 1 {
|
||||
out = append(out, strings.TrimSpace(match[1]))
|
||||
}
|
||||
}
|
||||
if start, end := strings.IndexAny(text, "[{"), strings.LastIndexAny(text, "]}"); start >= 0 && end > start {
|
||||
out = append(out, strings.TrimSpace(text[start:end+1]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decodeTextToolCalls(candidate string, allowed map[string]string) []ai.ToolCall {
|
||||
func decodeTextToolCalls(candidate string, allowed map[string]bool) []ai.ToolCall {
|
||||
var root any
|
||||
if err := json.Unmarshal([]byte(candidate), &root); err != nil {
|
||||
return nil
|
||||
@@ -164,7 +90,7 @@ func decodeTextToolCalls(candidate string, allowed map[string]string) []ai.ToolC
|
||||
return collectTextToolCalls(root, allowed)
|
||||
}
|
||||
|
||||
func collectTextToolCalls(v any, allowed map[string]string) []ai.ToolCall {
|
||||
func collectTextToolCalls(v any, allowed map[string]bool) []ai.ToolCall {
|
||||
switch x := v.(type) {
|
||||
case []any:
|
||||
var out []ai.ToolCall
|
||||
@@ -177,100 +103,27 @@ func collectTextToolCalls(v any, allowed map[string]string) []ai.ToolCall {
|
||||
return collectTextToolCalls(nested, allowed)
|
||||
}
|
||||
call := mapToTextToolCall(x)
|
||||
name, input := textToolCallNameAndInput(call)
|
||||
if name == "" || allowed[name] == "" || input == nil {
|
||||
name := call.Name
|
||||
if name == "" {
|
||||
name = call.Tool
|
||||
}
|
||||
input := call.Input
|
||||
if input == nil {
|
||||
input = call.Arguments
|
||||
}
|
||||
if name == "" || !allowed[name] || input == nil {
|
||||
return nil
|
||||
}
|
||||
id := call.ID
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("text-call-%s", strings.ReplaceAll(name, ".", "_"))
|
||||
}
|
||||
return []ai.ToolCall{{ID: id, Name: allowed[name], Input: input}}
|
||||
return []ai.ToolCall{{ID: id, Name: name, Input: input}}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func textToolCallNameAndInput(call textToolCall) (string, map[string]any) {
|
||||
name := call.Name
|
||||
if name == "" {
|
||||
name = call.Tool
|
||||
}
|
||||
input := call.Input
|
||||
if input == nil {
|
||||
input = textToolArguments(call.Arguments)
|
||||
}
|
||||
if call.Function != nil {
|
||||
fnName, fnInput := textToolCallNameAndInput(*call.Function)
|
||||
if name == "" {
|
||||
name = fnName
|
||||
}
|
||||
if input == nil {
|
||||
input = fnInput
|
||||
}
|
||||
}
|
||||
return name, input
|
||||
}
|
||||
|
||||
func textToolArguments(raw any) map[string]any {
|
||||
switch args := raw.(type) {
|
||||
case map[string]any:
|
||||
return args
|
||||
case string:
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal([]byte(args), &input); err == nil {
|
||||
return input
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeTaggedTextToolCalls(text string, allowed map[string]string) []ai.ToolCall {
|
||||
var out []ai.ToolCall
|
||||
for _, match := range singleTaggedToolCall.FindAllStringSubmatch(text, -1) {
|
||||
if len(match) < 3 {
|
||||
continue
|
||||
}
|
||||
tag, body := match[1], strings.TrimSpace(match[2])
|
||||
if calls := decodeTextToolCalls(body, allowed); len(calls) > 0 {
|
||||
out = append(out, calls...)
|
||||
continue
|
||||
}
|
||||
if calls := decodeTaggedTextToolCalls(body, allowed); len(calls) > 0 {
|
||||
out = append(out, calls...)
|
||||
continue
|
||||
}
|
||||
name := taggedToolName(tag)
|
||||
if name == "" || allowed[name] == "" {
|
||||
continue
|
||||
}
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal([]byte(body), &input); err != nil || input == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, ai.ToolCall{
|
||||
ID: fmt.Sprintf("text-call-%s", strings.ReplaceAll(name, ".", "_")),
|
||||
Name: allowed[name],
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func taggedToolName(tag string) string {
|
||||
for _, marker := range []string{"function=", "name=", "tool="} {
|
||||
if idx := strings.Index(tag, marker); idx >= 0 {
|
||||
name := strings.TrimSpace(tag[idx+len(marker):])
|
||||
name = strings.Trim(name, `"'`)
|
||||
if end := strings.IndexAny(name, " \t\r\n>"); end >= 0 {
|
||||
name = name[:end]
|
||||
}
|
||||
return strings.Trim(name, `"'`)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNestedToolCalls(m map[string]any) (any, bool) {
|
||||
for _, key := range []string{"tool_calls", "toolCalls", "calls"} {
|
||||
if v, ok := m[key]; ok {
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
func TestParseTextToolCallsMiniMaxTaggedMarkup(t *testing.T) {
|
||||
tools := []ai.Tool{{Name: "task_TaskService_Add"}}
|
||||
reply := `<tool_calls>
|
||||
<tool_call>{"name":"task_TaskService_Add","arguments":{"title":"Design"}}</tool_call>
|
||||
<tool_call>{"name":"task_TaskService_Add","arguments":{"title":"Build"}}</tool_call>
|
||||
<tool_call>{"name":"task_TaskService_Add","arguments":{"title":"Ship"}}</tool_call>
|
||||
</tool_calls>`
|
||||
|
||||
calls := parseTextToolCalls(reply, tools)
|
||||
if len(calls) != 3 {
|
||||
t.Fatalf("parseTextToolCalls returned %d calls, want 3: %+v", len(calls), calls)
|
||||
}
|
||||
for i, want := range []string{"Design", "Build", "Ship"} {
|
||||
if calls[i].Name != "task_TaskService_Add" {
|
||||
t.Fatalf("call %d name = %q, want task_TaskService_Add", i, calls[i].Name)
|
||||
}
|
||||
if got := calls[i].Input["title"]; got != want {
|
||||
t.Fatalf("call %d title = %v, want %q", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTextToolCallsFunctionTaggedMarkup(t *testing.T) {
|
||||
tools := []ai.Tool{{Name: "task_TaskService_Add"}}
|
||||
reply := `<function=task_TaskService_Add>{"title":"Design"}</function>`
|
||||
|
||||
calls := parseTextToolCalls(reply, tools)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("parseTextToolCalls returned %d calls, want 1: %+v", len(calls), calls)
|
||||
}
|
||||
if got := calls[0].Input["title"]; got != "Design" {
|
||||
t.Fatalf("title = %v, want Design", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTextToolCallsCreateAliasForAddTool(t *testing.T) {
|
||||
tools := []ai.Tool{{Name: "task_TaskService_Add", OriginalName: "task.TaskService.Add"}}
|
||||
reply := `<tool_call>{"name":"task_TaskService_Create","arguments":{"title":"Design"}}</tool_call>`
|
||||
|
||||
calls := parseTextToolCalls(reply, tools)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("parseTextToolCalls returned %d calls, want 1: %+v", len(calls), calls)
|
||||
}
|
||||
if calls[0].Name != "task_TaskService_Add" {
|
||||
t.Fatalf("call name = %q, want canonical task_TaskService_Add", calls[0].Name)
|
||||
}
|
||||
if got := calls[0].Input["title"]; got != "Design" {
|
||||
t.Fatalf("title = %v, want Design", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTextToolCallsOpenAICompatibleFunctionArgumentsString(t *testing.T) {
|
||||
tools := []ai.Tool{{Name: "delegate"}}
|
||||
reply := `<tool_call>{"id":"call-2","type":"function","function":{"name":"delegate","arguments":"{\"task\":\"summarize the conformance marker\",\"to\":\"blocked-reviewer\"}"}}</tool_call>`
|
||||
|
||||
calls := parseTextToolCalls(reply, tools)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("parseTextToolCalls returned %d calls, want 1: %+v", len(calls), calls)
|
||||
}
|
||||
if calls[0].Name != "delegate" {
|
||||
t.Fatalf("call name = %q, want delegate", calls[0].Name)
|
||||
}
|
||||
if got := calls[0].Input["task"]; got != "summarize the conformance marker" {
|
||||
t.Fatalf("task = %v, want summarize the conformance marker", got)
|
||||
}
|
||||
if got := calls[0].Input["to"]; got != "blocked-reviewer" {
|
||||
t.Fatalf("to = %v, want blocked-reviewer", got)
|
||||
}
|
||||
}
|
||||
+2
-108
@@ -2,7 +2,6 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -18,7 +17,6 @@ func init() {
|
||||
ai.Register("anthropic", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
ai.RegisterStream("anthropic")
|
||||
}
|
||||
|
||||
// Provider implements the ai.Model interface for Anthropic Claude
|
||||
@@ -158,113 +156,9 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Stream generates a streaming response from Anthropic's Messages SSE API.
|
||||
// Stream generates a streaming response (not yet implemented)
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"max_tokens": anthropicMaxTokens(p.opts),
|
||||
"system": req.SystemPrompt,
|
||||
"messages": threadAnthropicMessages(req),
|
||||
"stream": true,
|
||||
}
|
||||
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, "/") + "/v1/messages"
|
||||
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-api-key", p.opts.APIKey)
|
||||
httpReq.Header.Set("anthropic-version", "2023-06-01")
|
||||
|
||||
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, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(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:"))
|
||||
var chunk struct {
|
||||
Type string `json:"type"`
|
||||
Delta struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"delta"`
|
||||
Message struct {
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
} `json:"message"`
|
||||
Usage *struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
|
||||
}
|
||||
switch chunk.Type {
|
||||
case "content_block_delta":
|
||||
if chunk.Delta.Type == "text_delta" && chunk.Delta.Text != "" {
|
||||
return &ai.Response{Reply: chunk.Delta.Text}, nil
|
||||
}
|
||||
case "message_start":
|
||||
if chunk.Message.Usage.InputTokens > 0 || chunk.Message.Usage.OutputTokens > 0 {
|
||||
return &ai.Response{Usage: usage(chunk.Message.Usage.InputTokens, chunk.Message.Usage.OutputTokens)}, nil
|
||||
}
|
||||
case "message_delta":
|
||||
if chunk.Usage != nil {
|
||||
return &ai.Response{Usage: usage(chunk.Usage.InputTokens, chunk.Usage.OutputTokens)}, nil
|
||||
}
|
||||
case "message_stop":
|
||||
return nil, io.EOF
|
||||
case "error":
|
||||
return nil, fmt.Errorf("anthropic stream error: %s", data)
|
||||
}
|
||||
}
|
||||
if err := s.scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
func (s *streamReader) Close() error {
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
return s.body.Close()
|
||||
}
|
||||
|
||||
func usage(input, output int) ai.Usage {
|
||||
return ai.Usage{InputTokens: input, OutputTokens: output, TotalTokens: input + output}
|
||||
return nil, fmt.Errorf("%w: anthropic provider", ai.ErrStreamingUnsupported)
|
||||
}
|
||||
|
||||
// callAPI makes an HTTP request to the Anthropic API
|
||||
|
||||
@@ -3,10 +3,6 @@ package anthropic
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -85,67 +81,15 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Stream(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/messages" {
|
||||
t.Fatalf("path = %q, want /v1/messages", r.URL.Path)
|
||||
}
|
||||
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-api-key"); got != "test-key" {
|
||||
t.Fatalf("x-api-key = %q, want test-key", got)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if !strings.Contains(string(body), `"stream":true`) {
|
||||
t.Fatalf("request body %s does not enable streaming", string(body))
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("event: message_start\n"))
|
||||
_, _ = w.Write([]byte(`data: {"type":"message_start","message":{"usage":{"input_tokens":2}}}` + "\n\n"))
|
||||
_, _ = w.Write([]byte("event: content_block_delta\n"))
|
||||
_, _ = w.Write([]byte(`data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hel"}}` + "\n\n"))
|
||||
_, _ = w.Write([]byte("event: content_block_delta\n"))
|
||||
_, _ = w.Write([]byte(`data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"lo"}}` + "\n\n"))
|
||||
_, _ = w.Write([]byte("event: message_delta\n"))
|
||||
_, _ = w.Write([]byte(`data: {"type":"message_delta","usage":{"output_tokens":3}}` + "\n\n"))
|
||||
_, _ = w.Write([]byte("event: message_stop\n"))
|
||||
_, _ = w.Write([]byte(`data: {"type":"message_stop"}` + "\n\n"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
req := &ai.Request{
|
||||
Prompt: "Hello",
|
||||
}
|
||||
|
||||
stream, err := p.Stream(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Stream failed: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
var reply strings.Builder
|
||||
var usage ai.Usage
|
||||
for {
|
||||
chunk, err := stream.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Recv failed: %v", err)
|
||||
}
|
||||
reply.WriteString(chunk.Reply)
|
||||
if chunk.Usage.TotalTokens > 0 {
|
||||
usage = chunk.Usage
|
||||
}
|
||||
}
|
||||
if got := reply.String(); got != "hello" {
|
||||
t.Fatalf("reply = %q, want hello", got)
|
||||
}
|
||||
if usage.TotalTokens != 3 {
|
||||
t.Fatalf("usage = %+v, want total 3", usage)
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if !errors.Is(err, ai.ErrStreamingUnsupported) {
|
||||
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
|
||||
}
|
||||
}
|
||||
|
||||
+18
-163
@@ -24,7 +24,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -94,8 +93,20 @@ func (p *Provider) Options() ai.Options { return p.opts }
|
||||
func (p *Provider) String() string { return "atlascloud" }
|
||||
|
||||
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
tools := atlascloudTools(req.Tools)
|
||||
compatTools, compatPrompt := atlascloudMinimaxCompatTools(p.opts.Model, req.Tools)
|
||||
var tools []map[string]any
|
||||
for _, t := range req.Tools {
|
||||
tools = append(tools, map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": t.Name,
|
||||
"description": t.Description,
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": t.Properties,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": req.SystemPrompt},
|
||||
@@ -106,9 +117,6 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
if req.Prompt != "" {
|
||||
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
|
||||
}
|
||||
if compatPrompt != "" {
|
||||
messages = append(messages, map[string]any{"role": "system", "content": compatPrompt})
|
||||
}
|
||||
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
@@ -124,13 +132,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
|
||||
resp, rawMessage, err := p.callAPI(ctx, "chat", apiReq)
|
||||
if err != nil {
|
||||
if atlascloudShouldRetryMinimaxCompat(err, compatTools) {
|
||||
apiReq["tools"] = compatTools
|
||||
resp, rawMessage, err = p.callAPI(ctx, "chat-minimax-compat", apiReq)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
@@ -138,7 +140,6 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
}
|
||||
|
||||
if p.opts.ToolHandler != nil {
|
||||
allToolCalls := append([]ai.ToolCall(nil), resp.ToolCalls...)
|
||||
var toolResults []string
|
||||
followUpMessages := append(messages, map[string]any{
|
||||
"role": "assistant",
|
||||
@@ -162,50 +163,13 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
"model": p.opts.Model,
|
||||
"messages": followUpMessages,
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
// Keep the tool schema available during the follow-up turn. Minimax
|
||||
// models behind Atlas Cloud sometimes call one required tool, inspect
|
||||
// that result, and then issue a second tool call (for example a guarded
|
||||
// delegate conformance check) instead of completing immediately.
|
||||
followUpReq["tools"] = tools
|
||||
}
|
||||
|
||||
followUpResp, _, err := p.callAPI(ctx, "tool-follow-up", followUpReq)
|
||||
if err != nil {
|
||||
if atlascloudShouldRetryWithoutTools(err, followUpReq) {
|
||||
delete(followUpReq, "tools")
|
||||
followUpResp, _, err = p.callAPI(ctx, "tool-follow-up-no-tools", followUpReq)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(followUpResp.ToolCalls) > 0 {
|
||||
for i := range followUpResp.ToolCalls {
|
||||
result := p.opts.ToolHandler(ctx, followUpResp.ToolCalls[i])
|
||||
if result.Refused != "" {
|
||||
followUpResp.ToolCalls[i].Error = result.Refused
|
||||
}
|
||||
if result.Content != "" {
|
||||
followUpResp.ToolCalls[i].Result = result.Content
|
||||
toolResults = append(toolResults, result.Content)
|
||||
}
|
||||
}
|
||||
allToolCalls = append(allToolCalls, followUpResp.ToolCalls...)
|
||||
resp.ToolCalls = allToolCalls
|
||||
return nil, err
|
||||
}
|
||||
if followUpResp.Reply != "" {
|
||||
if strings.Contains(followUpResp.Reply, "<tool_call") || strings.Contains(followUpResp.Reply, "function=") {
|
||||
// Preserve follow-up assistant content as Reply, not Answer, when
|
||||
// it may contain a text-encoded tool call. The agent harness
|
||||
// inspects Reply for text fallback calls after Generate returns,
|
||||
// which covers AtlasCloud/minimax turns that emit a second
|
||||
// required call (for example guarded delegate) as markup instead
|
||||
// of native tool_calls.
|
||||
resp.Reply = followUpResp.Reply
|
||||
} else {
|
||||
resp.Answer = followUpResp.Reply
|
||||
}
|
||||
resp.Answer = followUpResp.Reply
|
||||
} else if len(toolResults) > 0 {
|
||||
resp.Answer = strings.Join(toolResults, "\n")
|
||||
}
|
||||
@@ -325,18 +289,6 @@ func (s *atlasStream) Close() error {
|
||||
return s.body.Close()
|
||||
}
|
||||
|
||||
type atlascloudAPIError struct {
|
||||
Status string
|
||||
StatusCode int
|
||||
Phase string
|
||||
Summary string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *atlascloudAPIError) Error() string {
|
||||
return fmt.Sprintf("API error (%s) during atlascloud %s request (%s): %s", e.Status, e.Phase, e.Summary, e.Body)
|
||||
}
|
||||
|
||||
func (p *Provider) callAPI(ctx context.Context, phase string, req map[string]any) (*ai.Response, map[string]any, error) {
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
@@ -360,7 +312,7 @@ func (p *Provider) callAPI(ctx context.Context, phase string, req map[string]any
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, nil, &atlascloudAPIError{Status: httpResp.Status, StatusCode: httpResp.StatusCode, Phase: phase, Summary: atlascloudRequestSummary(req), Body: string(respBody)}
|
||||
return nil, nil, fmt.Errorf("API error (%s) during atlascloud %s request (%s): %s", httpResp.Status, phase, atlascloudRequestSummary(req), string(respBody))
|
||||
}
|
||||
|
||||
var chatResp struct {
|
||||
@@ -405,103 +357,6 @@ func (p *Provider) callAPI(ctx context.Context, phase string, req map[string]any
|
||||
return response, rawMessage, nil
|
||||
}
|
||||
|
||||
func atlascloudMinimaxCompatTools(model string, input []ai.Tool) ([]map[string]any, string) {
|
||||
if !atlascloudIsMinimaxModel(model) || len(input) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
var native []ai.Tool
|
||||
var builtins []string
|
||||
for _, tool := range input {
|
||||
switch tool.Name {
|
||||
case "plan", "request_input", "delegate":
|
||||
builtins = append(builtins, tool.Name)
|
||||
default:
|
||||
native = append(native, tool)
|
||||
}
|
||||
}
|
||||
if len(builtins) == 0 || len(native) == len(input) {
|
||||
return nil, ""
|
||||
}
|
||||
prompt := "AtlasCloud/minimax compatibility: use native tool_calls for the listed service tools. " +
|
||||
"For built-in agent tools that are not listed natively (" + strings.Join(builtins, ", ") +
|
||||
"), emit exactly <tool_call name=\"tool_name\">{...}</tool_call> so the agent runtime can execute them. Do not describe those built-in tool calls in prose instead of emitting the tag."
|
||||
return atlascloudTools(native), prompt
|
||||
}
|
||||
|
||||
func atlascloudIsMinimaxModel(model string) bool {
|
||||
model = strings.ToLower(model)
|
||||
return strings.Contains(model, "minimax")
|
||||
}
|
||||
|
||||
func atlascloudShouldRetryMinimaxCompat(err error, compatTools []map[string]any) bool {
|
||||
if len(compatTools) == 0 {
|
||||
return false
|
||||
}
|
||||
var apiErr *atlascloudAPIError
|
||||
return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusBadRequest
|
||||
}
|
||||
|
||||
func atlascloudShouldRetryWithoutTools(err error, req map[string]any) bool {
|
||||
if _, ok := req["tools"]; !ok {
|
||||
return false
|
||||
}
|
||||
var apiErr *atlascloudAPIError
|
||||
return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusBadRequest
|
||||
}
|
||||
|
||||
func atlascloudTools(input []ai.Tool) []map[string]any {
|
||||
tools := make([]map[string]any, 0, len(input))
|
||||
for _, t := range input {
|
||||
tools = append(tools, map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": t.Name,
|
||||
"description": t.Description,
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": normalizeAtlasCloudSchema(t.Properties),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
func normalizeAtlasCloudSchema(schema map[string]any) map[string]any {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(schema))
|
||||
for k, v := range schema {
|
||||
out[k] = normalizeAtlasCloudSchemaValue(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeAtlasCloudSchemaValue(v any) any {
|
||||
switch val := v.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(val)+1)
|
||||
for k, nested := range val {
|
||||
out[k] = normalizeAtlasCloudSchemaValue(nested)
|
||||
}
|
||||
if typ, _ := out["type"].(string); typ == "array" {
|
||||
if _, ok := out["items"]; !ok {
|
||||
out["items"] = map[string]any{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(val))
|
||||
for i, nested := range val {
|
||||
out[i] = normalizeAtlasCloudSchemaValue(nested)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAtlasCloudToolCalls(toolCalls []atlasToolCall) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(toolCalls))
|
||||
for _, tc := range toolCalls {
|
||||
|
||||
@@ -289,285 +289,6 @@ func TestProvider_GenerateMinimaxToolRequests(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GenerateNormalizesBuiltInToolSchemas(t *testing.T) {
|
||||
var body map[string]any
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
planProperties := map[string]any{
|
||||
"steps": map[string]any{
|
||||
"type": "array",
|
||||
"description": "ordered plan steps",
|
||||
},
|
||||
}
|
||||
p := NewProvider(
|
||||
ai.WithAPIKey("test-key"),
|
||||
ai.WithBaseURL(ts.URL),
|
||||
ai.WithModel("minimaxai/minimax-m3"),
|
||||
)
|
||||
_, err := p.Generate(context.Background(), &ai.Request{
|
||||
Prompt: "plan and delegate",
|
||||
Tools: []ai.Tool{
|
||||
{Name: "task_TaskService_Add", Description: "add task", Properties: map[string]any{"title": map[string]any{"type": "string"}}},
|
||||
{Name: "plan", Description: "record a plan", Properties: planProperties},
|
||||
{Name: "request_input", Description: "request input", Properties: map[string]any{"prompt": map[string]any{"type": "string"}}},
|
||||
{Name: "delegate", Description: "delegate work", Properties: map[string]any{"task": map[string]any{"type": "string"}, "to": map[string]any{"type": "string"}}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
|
||||
tools := body["tools"].([]any)
|
||||
if len(tools) != 4 {
|
||||
t.Fatalf("tools = %d, want custom tool plus built-ins", len(tools))
|
||||
}
|
||||
planTool := tools[1].(map[string]any)
|
||||
fn := planTool["function"].(map[string]any)
|
||||
params := fn["parameters"].(map[string]any)
|
||||
props := params["properties"].(map[string]any)
|
||||
steps := props["steps"].(map[string]any)
|
||||
if _, ok := steps["items"].(map[string]any); !ok {
|
||||
t.Fatalf("plan steps schema = %#v, want array items for AtlasCloud/minimax", steps)
|
||||
}
|
||||
if _, mutated := planProperties["steps"].(map[string]any)["items"]; mutated {
|
||||
t.Fatalf("Generate mutated caller tool schema: %#v", planProperties)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GenerateExecutesFollowUpToolCall(t *testing.T) {
|
||||
var bodies []map[string]any
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
bodies = append(bodies, body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch len(bodies) {
|
||||
case 1:
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-1","function":{"name":"conformance_echo","arguments":"{\"value\":\"agent-conformance\"}"}}]}}]}`))
|
||||
case 2:
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-2","function":{"name":"delegate","arguments":"{\"task\":\"summarize the conformance marker\",\"to\":\"blocked-reviewer\"}"}}]}}]}`))
|
||||
default:
|
||||
t.Fatalf("unexpected API call %d", len(bodies))
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
var sawEcho, sawDelegate bool
|
||||
p := NewProvider(
|
||||
ai.WithAPIKey("test-key"),
|
||||
ai.WithBaseURL(ts.URL),
|
||||
ai.WithToolHandler(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
switch call.Name {
|
||||
case "conformance_echo":
|
||||
sawEcho = true
|
||||
return ai.ToolResult{ID: call.ID, Content: `{"marker":"agent-conformance-ok"}`}
|
||||
case "delegate":
|
||||
sawDelegate = true
|
||||
return ai.ToolResult{ID: call.ID, Refused: ai.RefusedApproval, Content: "blocked by policy"}
|
||||
default:
|
||||
t.Fatalf("unexpected tool call %+v", call)
|
||||
return ai.ToolResult{}
|
||||
}
|
||||
}),
|
||||
)
|
||||
resp, err := p.Generate(context.Background(), &ai.Request{
|
||||
Prompt: "run conformance",
|
||||
Tools: []ai.Tool{
|
||||
{Name: "conformance_echo", Description: "echo conformance marker", Properties: map[string]any{"value": map[string]any{"type": "string"}}},
|
||||
{Name: "delegate", Description: "delegate work", Properties: map[string]any{"task": map[string]any{"type": "string"}, "to": map[string]any{"type": "string"}}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
if !sawEcho || !sawDelegate {
|
||||
t.Fatalf("sawEcho=%v sawDelegate=%v, want both tools executed", sawEcho, sawDelegate)
|
||||
}
|
||||
if len(resp.ToolCalls) != 2 {
|
||||
t.Fatalf("ToolCalls = %+v, want echo and delegate", resp.ToolCalls)
|
||||
}
|
||||
if resp.ToolCalls[1].Name != "delegate" || resp.ToolCalls[1].Error != ai.RefusedApproval {
|
||||
t.Fatalf("follow-up delegate = %+v, want refused delegate", resp.ToolCalls[1])
|
||||
}
|
||||
if !strings.Contains(resp.Answer, "blocked by policy") {
|
||||
t.Fatalf("Answer = %q, want follow-up tool result", resp.Answer)
|
||||
}
|
||||
if _, ok := bodies[1]["tools"].([]any); !ok {
|
||||
t.Fatalf("follow-up request did not include tools: %#v", bodies[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GeneratePreservesFollowUpTextToolCallInReply(t *testing.T) {
|
||||
var bodies []map[string]any
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
bodies = append(bodies, body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch len(bodies) {
|
||||
case 1:
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-1","function":{"name":"conformance_echo","arguments":"{\"value\":\"agent-conformance\"}"}}]}}]}`))
|
||||
case 2:
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"<tool_call name=\"delegate\">{\"task\":\"summarize the conformance marker\",\"to\":\"blocked-reviewer\"}</tool_call>"}}]}`))
|
||||
default:
|
||||
t.Fatalf("unexpected API call %d", len(bodies))
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := NewProvider(
|
||||
ai.WithAPIKey("test-key"),
|
||||
ai.WithBaseURL(ts.URL),
|
||||
ai.WithToolHandler(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
if call.Name != "conformance_echo" {
|
||||
t.Fatalf("unexpected structured tool call %+v", call)
|
||||
}
|
||||
return ai.ToolResult{ID: call.ID, Content: `{"marker":"agent-conformance-ok"}`}
|
||||
}),
|
||||
)
|
||||
resp, err := p.Generate(context.Background(), &ai.Request{
|
||||
Prompt: "run conformance",
|
||||
Tools: []ai.Tool{
|
||||
{Name: "conformance_echo", Description: "echo conformance marker", Properties: map[string]any{"value": map[string]any{"type": "string"}}},
|
||||
{Name: "delegate", Description: "delegate work", Properties: map[string]any{"task": map[string]any{"type": "string"}, "to": map[string]any{"type": "string"}}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(resp.Reply, `<tool_call name="delegate">`) {
|
||||
t.Fatalf("Reply = %q, want tagged delegate follow-up for agent text fallback", resp.Reply)
|
||||
}
|
||||
if resp.Answer != "" {
|
||||
t.Fatalf("Answer = %q, want follow-up text preserved only as Reply", resp.Answer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GenerateRetriesMinimaxBuiltInsAsTextTools(t *testing.T) {
|
||||
var bodies []map[string]any
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
bodies = append(bodies, body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch len(bodies) {
|
||||
case 1:
|
||||
http.Error(w, `{"code":400,"msg":"bad request"}`, http.StatusBadRequest)
|
||||
case 2:
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"<tool_call name=\"delegate\">{\"task\":\"summarize\",\"to\":\"blocked-reviewer\"}</tool_call>"}}]}`))
|
||||
default:
|
||||
t.Fatalf("unexpected API call %d", len(bodies))
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL), ai.WithModel("minimaxai/minimax-m3"))
|
||||
resp, err := p.Generate(context.Background(), &ai.Request{
|
||||
Prompt: "plan and delegate",
|
||||
Tools: []ai.Tool{
|
||||
{Name: "task_TaskService_Add", Description: "add task", Properties: map[string]any{"title": map[string]any{"type": "string"}}},
|
||||
{Name: "plan", Description: "record a plan", Properties: map[string]any{"steps": map[string]any{"type": "array"}}},
|
||||
{Name: "request_input", Description: "request input", Properties: map[string]any{"prompt": map[string]any{"type": "string"}}},
|
||||
{Name: "delegate", Description: "delegate work", Properties: map[string]any{"task": map[string]any{"type": "string"}, "to": map[string]any{"type": "string"}}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(resp.Reply, `<tool_call name="delegate">`) {
|
||||
t.Fatalf("Reply = %q, want text delegate fallback", resp.Reply)
|
||||
}
|
||||
if len(bodies) != 2 {
|
||||
t.Fatalf("requests = %d, want initial plus compat retry", len(bodies))
|
||||
}
|
||||
initialTools := bodies[0]["tools"].([]any)
|
||||
if len(initialTools) != 4 {
|
||||
t.Fatalf("initial tools = %d, want all tools", len(initialTools))
|
||||
}
|
||||
retryTools := bodies[1]["tools"].([]any)
|
||||
if len(retryTools) != 1 {
|
||||
t.Fatalf("retry tools = %d, want only service tools", len(retryTools))
|
||||
}
|
||||
fn := retryTools[0].(map[string]any)["function"].(map[string]any)
|
||||
if fn["name"] != "task_TaskService_Add" {
|
||||
t.Fatalf("retry tool name = %v, want service tool only", fn["name"])
|
||||
}
|
||||
msgs := bodies[1]["messages"].([]any)
|
||||
compat := msgs[len(msgs)-1].(map[string]any)
|
||||
if compat["role"] != "system" || !strings.Contains(compat["content"].(string), `<tool_call name="tool_name">`) {
|
||||
t.Fatalf("compat instruction = %#v", compat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GenerateFollowUpRetriesWithoutToolsOnBadRequest(t *testing.T) {
|
||||
var bodies []map[string]any
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
bodies = append(bodies, body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch len(bodies) {
|
||||
case 1:
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-1","function":{"name":"conformance_echo","arguments":"{\"value\":\"agent-conformance\"}"}}]}}]}`))
|
||||
case 2:
|
||||
http.Error(w, `{"code":400,"msg":"bad request"}`, http.StatusBadRequest)
|
||||
case 3:
|
||||
if _, ok := body["tools"]; ok {
|
||||
t.Fatalf("no-tools retry still included tools: %#v", body["tools"])
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"done"}}]}`))
|
||||
default:
|
||||
t.Fatalf("unexpected API call %d", len(bodies))
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
var toolCalls int
|
||||
p := NewProvider(
|
||||
ai.WithAPIKey("test-key"),
|
||||
ai.WithBaseURL(ts.URL),
|
||||
ai.WithModel("minimaxai/minimax-m3"),
|
||||
ai.WithToolHandler(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
toolCalls++
|
||||
return ai.ToolResult{ID: call.ID, Content: `{"marker":"agent-conformance-ok"}`}
|
||||
}),
|
||||
)
|
||||
resp, err := p.Generate(context.Background(), &ai.Request{
|
||||
Prompt: "call a tool",
|
||||
Tools: []ai.Tool{{Name: "conformance_echo", Description: "echo", Properties: map[string]any{"value": map[string]any{"type": "string"}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
if resp.Answer != "done" {
|
||||
t.Fatalf("Answer = %q, want done", resp.Answer)
|
||||
}
|
||||
if toolCalls != 1 {
|
||||
t.Fatalf("tool handler calls = %d, want one (no duplicate side effect)", toolCalls)
|
||||
}
|
||||
if len(bodies) != 3 {
|
||||
t.Fatalf("requests = %d, want chat, failed follow-up, no-tools follow-up", len(bodies))
|
||||
}
|
||||
if _, ok := bodies[1]["tools"]; !ok {
|
||||
t.Fatalf("first follow-up did not include tools")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GenerateToolCallHTTPErrorIncludesRequestContext(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"code":400,"msg":"bad request"}`, http.StatusBadRequest)
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestRegisteredProviders(t *testing.T) {
|
||||
}
|
||||
|
||||
got = ai.RegisteredProviders("stream")
|
||||
want = []string{"anthropic", "atlascloud", "groq", "minimax", "mistral", "openai", "together"}
|
||||
want = []string{"atlascloud", "groq", "minimax", "mistral", "openai", "together"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func TestRegisteredProviders(t *testing.T) {
|
||||
func TestCapabilityRows(t *testing.T) {
|
||||
got := ai.CapabilityRows()
|
||||
want := []ai.CapabilityRow{
|
||||
{Provider: "anthropic", Capabilities: ai.Capabilities{Model: true, Stream: true}},
|
||||
{Provider: "anthropic", Capabilities: ai.Capabilities{Model: true}},
|
||||
{Provider: "atlascloud", Capabilities: ai.Capabilities{Model: true, Image: true, Video: true, Stream: true}},
|
||||
{Provider: "gemini", Capabilities: ai.Capabilities{Model: true}},
|
||||
{Provider: "groq", Capabilities: ai.Capabilities{Model: true, Stream: true}},
|
||||
@@ -90,7 +90,7 @@ func TestRegisterStream(t *testing.T) {
|
||||
}
|
||||
|
||||
got := ai.RegisteredProviders("stream")
|
||||
want := []string{"anthropic", "atlascloud", "groq", "minimax", "mistral", "openai", "test-stream", "together"}
|
||||
want := []string{"atlascloud", "groq", "minimax", "mistral", "openai", "test-stream", "together"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
@@ -215,7 +215,6 @@ func TestConfiguredProviderStreamsSkipWithoutCredentials(t *testing.T) {
|
||||
{provider: "mistral", keyEnv: "MISTRAL_API_KEY", modelEnv: "MISTRAL_MODEL"},
|
||||
{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"},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.provider, func(t *testing.T) {
|
||||
@@ -257,7 +256,7 @@ func TestConfiguredProviderStreamsSkipWithoutCredentials(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUnsupportedProvidersReturnStreamingUnsupportedAndStayUnregistered(t *testing.T) {
|
||||
for _, provider := range []string{"gemini"} {
|
||||
for _, provider := range []string{"anthropic", "gemini"} {
|
||||
provider := provider
|
||||
t.Run(provider, func(t *testing.T) {
|
||||
if caps := ai.ProviderCapabilities(provider); caps.Stream {
|
||||
|
||||
@@ -52,26 +52,6 @@ This starts:
|
||||
|
||||
Open http://localhost:8080 to see your services and call them from the browser.
|
||||
|
||||
Call the generated service from another terminal:
|
||||
|
||||
```
|
||||
curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call \
|
||||
-H 'Content-Type: application/json' -d '{"name":"World"}'
|
||||
```
|
||||
|
||||
## First agent on-ramp
|
||||
|
||||
Once the scaffold → run → call path works, ask the installed CLI for the
|
||||
provider-free agent path:
|
||||
|
||||
```
|
||||
micro agent demo
|
||||
micro examples
|
||||
```
|
||||
|
||||
Those commands point at the smallest mock-model first-agent example, the no-secret
|
||||
transcript, and the support app before you add provider-backed chat.
|
||||
|
||||
### Output
|
||||
|
||||
```
|
||||
@@ -666,7 +646,6 @@ micro loop verify # check a repo is wired correctly
|
||||
| Builder | `loop-builder.yml` | Builds the top open item as a single-concern PR, auto-merged on green CI |
|
||||
| Triage | `loop-triage.yml` | Turns CI failures into scoped fix issues, back into the queue |
|
||||
| Coherence | `loop-coherence.yml` | Keeps README/docs/CHANGELOG aligned with the North Star *(opt-in)* |
|
||||
| Security | `loop-security.yml` | Audits for vulnerabilities and files them; never auto-merges fixes, never publishes exploit detail *(opt-in)* |
|
||||
| Release | `loop-release.yml` | Cuts the next patch tag when the branch has new commits *(opt-in)* |
|
||||
|
||||
The workflows are the **mechanism**; each dispatch role's instruction is an editable file in `.github/loop/prompts/` — the **policy**. Edit those prompts (and `.github/loop/NORTH_STAR.md`) to steer the loop without touching the CLI. That split is what lets go-micro itself use `micro loop` while keeping its own richer prompts.
|
||||
|
||||
@@ -14,31 +14,6 @@ import (
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
const noSecretDemoHelp = `No-secret first-agent demo
|
||||
|
||||
Use this when you want the fastest provider-free agent success path before
|
||||
configuring API keys. It runs the maintained support/first-agent transcript with
|
||||
the deterministic mock model used by CI:
|
||||
|
||||
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1
|
||||
|
||||
What this proves:
|
||||
- service tools can be called by an agent
|
||||
- chat behavior is exercised without contacting a live provider
|
||||
- run history can be inspected after the prompt
|
||||
|
||||
After it passes:
|
||||
- Build your own service-backed agent: https://go-micro.dev/docs/guides/your-first-agent.html
|
||||
- Diagnose provider-backed chat: https://go-micro.dev/docs/guides/debugging-agents.html
|
||||
- Walk the full 0→hero lifecycle: https://go-micro.dev/docs/guides/zero-to-hero.html
|
||||
|
||||
Use live-provider chat when you are ready for real model behavior:
|
||||
micro agent preflight # before micro run: prerequisites
|
||||
micro run
|
||||
micro chat
|
||||
micro agent doctor # after micro run: chat/gateway/inspect recovery
|
||||
micro inspect agent <name>`
|
||||
|
||||
func init() {
|
||||
cmd.Register(&cli.Command{
|
||||
Name: "runs",
|
||||
@@ -59,36 +34,16 @@ func init() {
|
||||
|
||||
cmd.Register(&cli.Command{
|
||||
Name: "agent",
|
||||
Usage: "Manage AI agents (try: micro agent demo)",
|
||||
Usage: "Manage AI agents",
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "demo",
|
||||
Usage: "Show the no-secret first-agent demo command",
|
||||
Description: `Print the provider-free first-agent path for new developers:
|
||||
the deterministic mock-model transcript, when to use it, and where to go next
|
||||
for live-provider chat and inspect/debugging.`,
|
||||
Action: func(c *cli.Context) error {
|
||||
fmt.Fprintln(c.App.Writer, noSecretDemoHelp)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "preflight",
|
||||
Usage: "Check local prerequisites before the first provider-backed agent",
|
||||
Name: "preflight",
|
||||
Aliases: []string{"doctor"},
|
||||
Usage: "Check local prerequisites before the first provider-backed agent",
|
||||
Action: func(c *cli.Context) error {
|
||||
return runAgentPreflight(os.Stdout, defaultPreflightDeps())
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "doctor",
|
||||
Usage: "Diagnose chat, gateway, registration, provider, and inspect recovery after micro run",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "gateway", Value: "http://localhost:8080", Usage: "Gateway URL started by micro run"},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
return runAgentDoctor(os.Stdout, defaultDoctorDeps(), c.String("gateway"))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list",
|
||||
Usage: "List registered agents",
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
goagent "go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
type doctorDeps struct {
|
||||
getenv func(string) string
|
||||
httpGet func(string) (*http.Response, error)
|
||||
listServices func() ([]*registry.Service, error)
|
||||
getService func(string) ([]*registry.Service, error)
|
||||
listRuns func(string) ([]goagent.RunSummary, error)
|
||||
}
|
||||
|
||||
func defaultDoctorDeps() doctorDeps {
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
return doctorDeps{
|
||||
getenv: defaultPreflightDeps().getenv,
|
||||
httpGet: client.Get,
|
||||
listServices: registry.ListServices,
|
||||
getService: registry.GetService,
|
||||
listRuns: func(name string) ([]goagent.RunSummary, error) {
|
||||
return goagent.ListRunSummariesWithOptions(store.DefaultStore, name, goagent.RunListOptions{Limit: 1})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runAgentDoctor(w io.Writer, deps doctorDeps, gateway string) error {
|
||||
if gateway == "" {
|
||||
gateway = "http://localhost:8080"
|
||||
}
|
||||
gateway = strings.TrimRight(gateway, "/")
|
||||
checks := agentDoctorChecks(deps, gateway)
|
||||
failures := 0
|
||||
fmt.Fprintln(w, "First-agent recovery doctor")
|
||||
for _, check := range checks {
|
||||
mark := "✓"
|
||||
if !check.OK {
|
||||
mark = "✗"
|
||||
failures++
|
||||
}
|
||||
fmt.Fprintf(w, " %s %s — %s\n", mark, check.Name, check.Detail)
|
||||
if !check.OK && check.Fix != "" {
|
||||
fmt.Fprintf(w, " Fix: %s\n", check.Fix)
|
||||
}
|
||||
if !check.OK && check.Next != "" {
|
||||
fmt.Fprintf(w, " Next: %s\n", check.Next)
|
||||
}
|
||||
}
|
||||
if failures > 0 {
|
||||
return fmt.Errorf("first-agent doctor found %d recovery boundary issue(s)", failures)
|
||||
}
|
||||
fmt.Fprintln(w, "\nReady: gateway, agent registration, chat settings, and inspect history are reachable.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func agentDoctorChecks(deps doctorDeps, gateway string) []preflightCheck {
|
||||
if deps.getenv == nil {
|
||||
deps.getenv = defaultPreflightDeps().getenv
|
||||
}
|
||||
if deps.httpGet == nil {
|
||||
deps.httpGet = http.Get
|
||||
}
|
||||
if deps.listServices == nil {
|
||||
deps.listServices = registry.ListServices
|
||||
}
|
||||
if deps.getService == nil {
|
||||
deps.getService = registry.GetService
|
||||
}
|
||||
if deps.listRuns == nil {
|
||||
deps.listRuns = func(name string) ([]goagent.RunSummary, error) {
|
||||
return goagent.ListRunSummariesWithOptions(store.DefaultStore, name, goagent.RunListOptions{Limit: 1})
|
||||
}
|
||||
}
|
||||
|
||||
checks := []preflightCheck{checkGateway(deps, gateway), checkChatSettings(deps, gateway)}
|
||||
agents, regCheck := checkAgentRegistration(deps)
|
||||
checks = append(checks, regCheck)
|
||||
checks = append(checks, checkRunHistory(deps, agents))
|
||||
checks = append(checks, checkProviderConfig(deps))
|
||||
return checks
|
||||
}
|
||||
|
||||
func checkGateway(deps doctorDeps, gateway string) preflightCheck {
|
||||
resp, err := deps.httpGet(gateway + "/agent")
|
||||
if err != nil {
|
||||
return preflightCheck{Name: "gateway /agent", Detail: err.Error(), Fix: "Start the local gateway with `micro run`, or pass the matching URL with `micro agent doctor --gateway http://localhost:<port>`.", Next: "Then open " + gateway + "/agent or retry `micro chat`."}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return preflightCheck{Name: "gateway /agent", Detail: fmt.Sprintf("%s returned %s", gateway+"/agent", resp.Status), Fix: "Confirm `micro run` is serving the web gateway and that auth/proxy settings are not blocking /agent.", Next: "See docs/guides/debugging-agents.html#chat-and-gateway-failures."}
|
||||
}
|
||||
return preflightCheck{Name: "gateway /agent", OK: true, Detail: gateway + "/agent is reachable"}
|
||||
}
|
||||
|
||||
func checkChatSettings(deps doctorDeps, gateway string) preflightCheck {
|
||||
resp, err := deps.httpGet(gateway + "/api/agent/settings")
|
||||
if err != nil {
|
||||
return preflightCheck{Name: "chat settings endpoint", Detail: err.Error(), Fix: "Keep `micro run` running and retry; the playground uses /api/agent/settings before chat prompts.", Next: "See docs/guides/debugging-agents.html#chat-and-gateway-failures."}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return preflightCheck{Name: "chat settings endpoint", Detail: fmt.Sprintf("returned %s", resp.Status), Fix: "Check gateway auth/proxy configuration or use the Agent settings page to confirm chat settings load.", Next: "See docs/guides/debugging-agents.html#provider-failures."}
|
||||
}
|
||||
var settings map[string]string
|
||||
_ = json.NewDecoder(resp.Body).Decode(&settings)
|
||||
if settings["provider"] != "" || settings["model"] != "" || settings["api_key"] != "" {
|
||||
return preflightCheck{Name: "chat settings endpoint", OK: true, Detail: "reachable with saved provider settings"}
|
||||
}
|
||||
return preflightCheck{Name: "chat settings endpoint", OK: true, Detail: "reachable; no saved provider settings"}
|
||||
}
|
||||
|
||||
func checkAgentRegistration(deps doctorDeps) ([]string, preflightCheck) {
|
||||
services, err := deps.listServices()
|
||||
if err != nil {
|
||||
return nil, preflightCheck{Name: "agent registration", Detail: err.Error(), Fix: "Keep the scaffolded agent process running under `micro run` and retry `micro agent list`.", Next: "See docs/guides/your-first-agent.html#run-your-agent."}
|
||||
}
|
||||
var agents []string
|
||||
for _, svc := range services {
|
||||
records, err := deps.getService(svc.Name)
|
||||
if err != nil || len(records) == 0 {
|
||||
continue
|
||||
}
|
||||
if serviceIsAgent(records[0]) {
|
||||
agents = append(agents, svc.Name)
|
||||
}
|
||||
}
|
||||
if len(agents) == 0 {
|
||||
return nil, preflightCheck{Name: "agent registration", Detail: "no registered agent services found", Fix: "Start an agent project with `micro run` and confirm `micro agent list` shows it.", Next: "Use docs/guides/no-secret-first-agent.html for a deterministic no-provider agent."}
|
||||
}
|
||||
return agents, preflightCheck{Name: "agent registration", OK: true, Detail: "found " + strings.Join(agents, ", ")}
|
||||
}
|
||||
|
||||
func serviceIsAgent(svc *registry.Service) bool {
|
||||
if svc.Metadata != nil && svc.Metadata["type"] == "agent" {
|
||||
return true
|
||||
}
|
||||
for _, node := range svc.Nodes {
|
||||
if node.Metadata != nil && node.Metadata["type"] == "agent" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func checkRunHistory(deps doctorDeps, agents []string) preflightCheck {
|
||||
if len(agents) == 0 {
|
||||
return preflightCheck{Name: "inspect run history", Detail: "skipped because no agent is registered", Fix: "Fix agent registration first, then chat once and run `micro inspect agent <name>`.", Next: "See docs/guides/debugging-agents.html#inspect-run-history."}
|
||||
}
|
||||
for _, name := range agents {
|
||||
runs, err := deps.listRuns(name)
|
||||
if err != nil {
|
||||
return preflightCheck{Name: "inspect run history", Detail: err.Error(), Fix: "Ensure the local store is writable and retry `micro inspect agent " + name + "`.", Next: "See docs/guides/debugging-agents.html#inspect-run-history."}
|
||||
}
|
||||
if len(runs) > 0 {
|
||||
return preflightCheck{Name: "inspect run history", OK: true, Detail: "recent runs available for " + name}
|
||||
}
|
||||
}
|
||||
return preflightCheck{Name: "inspect run history", Detail: "no recorded agent runs yet", Fix: "Send one prompt with `micro chat` or the /agent playground, then run `micro inspect agent " + agents[0] + "`.", Next: "See docs/guides/your-first-agent.html#inspect-what-happened."}
|
||||
}
|
||||
|
||||
func checkProviderConfig(deps doctorDeps) preflightCheck {
|
||||
check := checkProviderKey(preflightDeps{getenv: deps.getenv})
|
||||
check.Name = "provider configuration"
|
||||
if !check.OK {
|
||||
check.Detail = "no provider key found for live LLM chat"
|
||||
check.Fix = "For provider-backed chat, export MICRO_AI_API_KEY or a provider-specific key; for no-secret recovery, use the mock-model walkthrough."
|
||||
}
|
||||
return check
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
goagent "go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/registry"
|
||||
)
|
||||
|
||||
func doctorHTTP(status int, body string) func(string) (*http.Response, error) {
|
||||
return func(string) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: status, Status: "200 OK", Body: io.NopCloser(strings.NewReader(body))}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentDoctorPassesWhenRecoveryBoundariesReachable(t *testing.T) {
|
||||
deps := doctorDeps{
|
||||
getenv: func(key string) string {
|
||||
if key == "MICRO_AI_API_KEY" {
|
||||
return "set"
|
||||
}
|
||||
return ""
|
||||
},
|
||||
httpGet: doctorHTTP(200, `{"provider":"anthropic","model":"claude"}`),
|
||||
listServices: func() ([]*registry.Service, error) {
|
||||
return []*registry.Service{{Name: "assistant"}}, nil
|
||||
},
|
||||
getService: func(name string) ([]*registry.Service, error) {
|
||||
return []*registry.Service{{Name: name, Metadata: map[string]string{"type": "agent"}}}, nil
|
||||
},
|
||||
listRuns: func(name string) ([]goagent.RunSummary, error) {
|
||||
return []goagent.RunSummary{{RunID: "run-1", Status: "done"}}, nil
|
||||
},
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := runAgentDoctor(&out, deps, "http://example.test"); err != nil {
|
||||
t.Fatalf("runAgentDoctor() error = %v\n%s", err, out.String())
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"First-agent recovery doctor", "✓ gateway /agent", "✓ chat settings endpoint", "✓ agent registration", "✓ inspect run history", "✓ provider configuration", "Ready:"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentDoctorReportsActionableRecoveryFailures(t *testing.T) {
|
||||
deps := doctorDeps{
|
||||
getenv: func(string) string { return "" },
|
||||
httpGet: func(string) (*http.Response, error) { return nil, errors.New("connection refused") },
|
||||
listServices: func() ([]*registry.Service, error) {
|
||||
return []*registry.Service{{Name: "greeter"}}, nil
|
||||
},
|
||||
getService: func(name string) ([]*registry.Service, error) {
|
||||
return []*registry.Service{{Name: name}}, nil
|
||||
},
|
||||
listRuns: func(name string) ([]goagent.RunSummary, error) { return nil, nil },
|
||||
}
|
||||
var out bytes.Buffer
|
||||
err := runAgentDoctor(&out, deps, "http://localhost:8080")
|
||||
if err == nil {
|
||||
t.Fatal("runAgentDoctor() error = nil")
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"✗ gateway /agent", "micro run", "✗ chat settings endpoint", "✗ agent registration", "micro agent list", "✗ inspect run history", "micro inspect agent <name>", "✗ provider configuration", "docs/guides/no-secret-first-agent.html"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-87
@@ -24,85 +24,27 @@ import (
|
||||
_ "go-micro.dev/v6/cmd/micro/cli/remote"
|
||||
)
|
||||
|
||||
const zeroToHeroHelp = `0→hero no-secret lifecycle demo
|
||||
|
||||
Run this from a go-micro repository checkout when you want one command that
|
||||
proves the maintained services → agents → workflows path without provider keys:
|
||||
|
||||
./internal/harness/zero-to-hero-ci/run.sh
|
||||
|
||||
That script runs the same deterministic path CI uses:
|
||||
- CLI discovery for scaffold, run, chat, inspect, flow runs, and deploy dry-run
|
||||
- the smallest first-agent example
|
||||
- the support-desk reference app with services, an agent, a flow, and an approval gate
|
||||
- plan/delegate and universe harnesses with only the model mocked
|
||||
|
||||
If you only want the runnable examples first:
|
||||
go run ./examples/first-agent
|
||||
go run ./examples/support
|
||||
|
||||
Full local contract:
|
||||
make harness
|
||||
|
||||
Guide: https://go-micro.dev/docs/guides/zero-to-hero.html`
|
||||
|
||||
const examplesWayfinding = `First-agent examples (no provider key required)
|
||||
|
||||
Run these from a go-micro repository checkout in this order. For the complete
|
||||
examples map, open examples/INDEX.md:
|
||||
|
||||
1. Smallest service-backed agent
|
||||
go run ./examples/first-agent
|
||||
Proves an agent can call a service tool with the deterministic mock model.
|
||||
|
||||
2. No-secret support-agent transcript
|
||||
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1
|
||||
Exercises service tools, mock-model chat, and inspectable run history.
|
||||
|
||||
3. Full services → agents → workflows reference app
|
||||
go run ./examples/support
|
||||
Shows the support desk service, agent, workflow, and approval gate together.
|
||||
|
||||
Then continue the same path with the installed CLI:
|
||||
micro agent demo
|
||||
micro docs
|
||||
micro zero-to-hero
|
||||
|
||||
Guides:
|
||||
https://go-micro.dev/docs/guides/no-secret-first-agent.html
|
||||
https://go-micro.dev/docs/guides/your-first-agent.html
|
||||
https://go-micro.dev/docs/guides/debugging-agents.html
|
||||
https://go-micro.dev/docs/guides/zero-to-hero.html`
|
||||
|
||||
const docsWayfinding = `First-agent and 0→hero docs:
|
||||
|
||||
1. Start with the no-secret CLI demo
|
||||
micro agent demo
|
||||
This prints the maintained support-agent transcript command so you can
|
||||
prove service tools, mock-model chat, and inspectable run history without
|
||||
configuring a provider key.
|
||||
|
||||
2. No-secret first-agent transcript
|
||||
1. No-secret first-agent transcript
|
||||
https://go-micro.dev/docs/guides/no-secret-first-agent.html
|
||||
Run the maintained support agent without a provider key:
|
||||
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1
|
||||
|
||||
3. Your First Agent
|
||||
2. Your First Agent
|
||||
https://go-micro.dev/docs/guides/your-first-agent.html
|
||||
Build a service-backed agent, then use:
|
||||
micro agent preflight # before micro run: prerequisites
|
||||
micro agent preflight
|
||||
micro run
|
||||
micro chat
|
||||
micro agent doctor # after micro run: chat/gateway/inspect recovery
|
||||
|
||||
4. Debugging your agent
|
||||
3. Debugging your agent
|
||||
https://go-micro.dev/docs/guides/debugging-agents.html
|
||||
Inspect agent runs and memory with:
|
||||
micro agent doctor
|
||||
micro inspect agent <name>
|
||||
micro agent history <name>
|
||||
micro inspect agent
|
||||
micro runs <agent>
|
||||
|
||||
5. 0→hero Reference
|
||||
4. 0→hero Reference
|
||||
https://go-micro.dev/docs/guides/zero-to-hero.html
|
||||
Walk the scaffold → run → chat → inspect → deploy dry-run lifecycle.`
|
||||
|
||||
@@ -178,28 +120,6 @@ func init() {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
Name: "examples",
|
||||
Usage: "Show provider-free first-agent example paths",
|
||||
Description: `Print the maintained no-secret examples for the services → agents →
|
||||
workflows on-ramp: first-agent, transcript, support app, and matching guides.`,
|
||||
Action: func(ctx *cli.Context) error {
|
||||
fmt.Fprintln(ctx.App.Writer, examplesWayfinding)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "zero-to-hero",
|
||||
Usage: "Show the no-secret 0→hero lifecycle demo command",
|
||||
Description: `Print the maintained provider-free services → agents → workflows
|
||||
lifecycle command and the smaller runnable examples it covers.`,
|
||||
Aliases: []string{"hero"},
|
||||
Action: func(ctx *cli.Context) error {
|
||||
fmt.Fprintln(ctx.App.Writer, zeroToHeroHelp)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docs",
|
||||
Usage: "Show the first-agent and 0→hero documentation path",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package new
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"flag"
|
||||
"os"
|
||||
@@ -58,44 +57,6 @@ func TestZeroToOneNoMCPContract(t *testing.T) {
|
||||
generated.call(t, "Bob", "Hello Bob")
|
||||
}
|
||||
|
||||
func TestPrintNextStepsSurfacesFirstAgentPath(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
printNextSteps(&out, "helloworld", false)
|
||||
|
||||
for _, want := range []string{
|
||||
"cd helloworld",
|
||||
"micro agent preflight",
|
||||
"go run .",
|
||||
"micro chat",
|
||||
"micro inspect agent <name>",
|
||||
"micro agent demo",
|
||||
"micro docs",
|
||||
"your-first-agent.html",
|
||||
"zero-to-hero.html",
|
||||
"http://localhost:3001/mcp/tools",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("next steps missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintNextStepsNoMCPSkipsMCPHints(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
printNextSteps(&out, "worker", true)
|
||||
|
||||
for _, want := range []string{"micro agent preflight", "micro chat", "micro inspect agent <name>", "micro agent demo", "micro docs"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("--no-mcp next steps missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
for _, notWant := range []string{"http://localhost:3001/mcp/tools", "micro mcp serve"} {
|
||||
if strings.Contains(out.String(), notWant) {
|
||||
t.Fatalf("--no-mcp next steps should not include %q:\n%s", notWant, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type generatedService struct {
|
||||
dir string
|
||||
repoRoot string
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"go/build"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
@@ -281,29 +280,16 @@ func Run(ctx *cli.Context) error {
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf(" \033[32m✓\033[0m Service \033[36m%s\033[0m created\n\n", dir)
|
||||
printNextSteps(os.Stdout, dir, noMCP)
|
||||
return nil
|
||||
}
|
||||
|
||||
func printNextSteps(w io.Writer, dir string, noMCP bool) {
|
||||
fmt.Fprintln(w, " Next steps:")
|
||||
fmt.Fprintf(w, " cd %s\n", dir)
|
||||
fmt.Fprintln(w, " micro agent preflight")
|
||||
fmt.Fprintln(w, " go run .")
|
||||
fmt.Fprintln(w, " micro chat")
|
||||
fmt.Fprintln(w, " micro inspect agent <name>")
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, " First-agent path:")
|
||||
fmt.Fprintln(w, " micro agent demo")
|
||||
fmt.Fprintln(w, " micro docs")
|
||||
fmt.Fprintln(w, " https://go-micro.dev/docs/guides/your-first-agent.html")
|
||||
fmt.Fprintln(w, " https://go-micro.dev/docs/guides/zero-to-hero.html")
|
||||
fmt.Println(" Next steps:")
|
||||
fmt.Printf(" cd %s\n", dir)
|
||||
fmt.Println(" go run .")
|
||||
if !noMCP {
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, " MCP tools \033[36mhttp://localhost:3001/mcp/tools\033[0m\n")
|
||||
fmt.Fprintln(w, " Claude Code \033[2mmicro mcp serve\033[0m")
|
||||
fmt.Println()
|
||||
fmt.Printf(" MCP tools \033[36mhttp://localhost:3001/mcp/tools\033[0m\n")
|
||||
fmt.Println(" Claude Code \033[2mmicro mcp serve\033[0m")
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectTemplates(name string, noMCP bool) (mainTmpl, handlerTmpl, protoTmpl string) {
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
microcmd "go-micro.dev/v6/cmd"
|
||||
)
|
||||
|
||||
func TestExamplesWayfindingIndexStaysLinked(t *testing.T) {
|
||||
root := filepath.Join("..", "..")
|
||||
files := map[string]string{}
|
||||
for _, name := range []string{"README.md", "examples/README.md", "examples/INDEX.md"} {
|
||||
b, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
files[name] = string(b)
|
||||
}
|
||||
|
||||
for _, check := range []struct {
|
||||
file string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
file: "README.md",
|
||||
want: []string{"examples/INDEX.md", "examples/first-agent/", "examples/support/", "zero-to-hero.md"},
|
||||
},
|
||||
{
|
||||
file: "examples/README.md",
|
||||
want: []string{"./INDEX.md", "./first-agent/", "./support/", "./mcp/hello/", "./mcp/workflow/"},
|
||||
},
|
||||
{
|
||||
file: "examples/INDEX.md",
|
||||
want: []string{"go run ./examples/first-agent", "go run ./examples/support", "mcp/hello", "mcp/workflow", "flow-durable", "micro examples"},
|
||||
},
|
||||
} {
|
||||
for _, want := range check.want {
|
||||
if !strings.Contains(files[check.file], want) {
|
||||
t.Fatalf("%s missing %q", check.file, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExamplesCommandPointsAtWayfindingIndex(t *testing.T) {
|
||||
examples := commandByName(t, "examples")
|
||||
var out bytes.Buffer
|
||||
app := cli.NewApp()
|
||||
app.Writer = &out
|
||||
if err := examples.Action(cli.NewContext(app, nil, nil)); err != nil {
|
||||
t.Fatalf("micro examples failed: %v", err)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"examples/INDEX.md",
|
||||
"go run ./examples/first-agent",
|
||||
"go run ./examples/support",
|
||||
"micro zero-to-hero",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("micro examples output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
_ = microcmd.DefaultCmd // keep this test coupled to the registered command package.
|
||||
}
|
||||
@@ -22,7 +22,7 @@ func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
for _, want := range []string{"new", "run", "chat", "inspect", "agent", "docs", "examples"} {
|
||||
for _, want := range []string{"new", "run", "chat", "inspect", "agent", "docs"} {
|
||||
if !commands[want] {
|
||||
t.Fatalf("first-agent walkthrough missing %q command", want)
|
||||
}
|
||||
@@ -30,12 +30,6 @@ func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
|
||||
if !subcommands["agent"]["preflight"] {
|
||||
t.Fatal("first-agent walkthrough missing preflight boundary: agent preflight")
|
||||
}
|
||||
if !subcommands["agent"]["demo"] {
|
||||
t.Fatal("first-agent walkthrough missing no-secret boundary: agent demo")
|
||||
}
|
||||
if !subcommands["agent"]["doctor"] {
|
||||
t.Fatal("first-agent walkthrough missing recovery boundary: agent doctor")
|
||||
}
|
||||
if !subcommands["inspect"]["agent"] {
|
||||
t.Fatal("first-agent walkthrough missing inspect boundary: inspect agent")
|
||||
}
|
||||
@@ -55,88 +49,20 @@ func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
|
||||
if err := docs.Action(cli.NewContext(app, nil, nil)); err != nil {
|
||||
t.Fatalf("micro docs failed: %v", err)
|
||||
}
|
||||
if demoIdx, guideIdx := strings.Index(out.String(), "micro agent demo"), strings.Index(out.String(), "no-secret-first-agent.html"); demoIdx < 0 || guideIdx < 0 || demoIdx > guideIdx {
|
||||
t.Fatalf("micro docs should lead with micro agent demo before guide links:\n%s", out.String())
|
||||
}
|
||||
for _, want := range []string{
|
||||
"micro agent demo",
|
||||
"no-secret-first-agent.html",
|
||||
"your-first-agent.html",
|
||||
"debugging-agents.html",
|
||||
"zero-to-hero.html",
|
||||
"micro agent preflight # before micro run: prerequisites",
|
||||
"micro agent preflight",
|
||||
"micro run",
|
||||
"micro chat",
|
||||
"micro agent doctor # after micro run: chat/gateway/inspect recovery",
|
||||
"micro inspect agent <name>",
|
||||
"micro agent history <name>",
|
||||
"micro inspect agent",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("micro docs output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
if strings.Contains(out.String(), "micro runs") {
|
||||
t.Fatalf("micro docs output should use the first-agent inspect command, not the legacy runs shortcut:\n%s", out.String())
|
||||
}
|
||||
|
||||
examples := commandByName(t, "examples")
|
||||
if !strings.Contains(examples.Usage, "first-agent") {
|
||||
t.Fatalf("micro examples should advertise the first-agent examples path; usage was %q", examples.Usage)
|
||||
}
|
||||
out.Reset()
|
||||
if err := examples.Action(cli.NewContext(app, nil, nil)); err != nil {
|
||||
t.Fatalf("micro examples failed: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"First-agent examples",
|
||||
"go run ./examples/first-agent",
|
||||
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1",
|
||||
"go run ./examples/support",
|
||||
"micro agent demo",
|
||||
"micro docs",
|
||||
"micro zero-to-hero",
|
||||
"no-secret-first-agent.html",
|
||||
"your-first-agent.html",
|
||||
"debugging-agents.html",
|
||||
"zero-to-hero.html",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("micro examples output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
agent := commandByName(t, "agent")
|
||||
if !strings.Contains(agent.Usage, "micro agent demo") {
|
||||
t.Fatalf("micro agent help should advertise the no-secret demo; usage was %q", agent.Usage)
|
||||
}
|
||||
doctor := subcommandByName(t, agent, "doctor")
|
||||
for _, want := range []string{"chat", "gateway", "registration", "provider", "inspect", "after micro run"} {
|
||||
if !strings.Contains(doctor.Usage, want) {
|
||||
t.Fatalf("micro agent doctor usage should advertise after-run recovery for %q; usage was %q", want, doctor.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
demo := subcommandByName(t, agent, "demo")
|
||||
out.Reset()
|
||||
if err := demo.Action(cli.NewContext(app, nil, nil)); err != nil {
|
||||
t.Fatalf("micro agent demo failed: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"No-secret first-agent demo",
|
||||
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1",
|
||||
"provider-free",
|
||||
"micro agent preflight # before micro run: prerequisites",
|
||||
"micro chat",
|
||||
"micro agent doctor # after micro run: chat/gateway/inspect recovery",
|
||||
"micro inspect agent <name>",
|
||||
"your-first-agent.html",
|
||||
"debugging-agents.html",
|
||||
"zero-to-hero.html",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("micro agent demo output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func commandByName(t *testing.T, name string) *cli.Command {
|
||||
@@ -149,14 +75,3 @@ func commandByName(t *testing.T, name string) *cli.Command {
|
||||
t.Fatalf("missing command %q", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func subcommandByName(t *testing.T, command *cli.Command, name string) *cli.Command {
|
||||
t.Helper()
|
||||
for _, subcommand := range command.Subcommands {
|
||||
if subcommand.Name == name {
|
||||
return subcommand
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing subcommand %q under %q", name, command.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -85,12 +85,6 @@ func writeAgentInspection(w io.Writer, name string, runs []goagent.RunSummary, a
|
||||
fmt.Fprintf(w, " Agent %q runs\n", name)
|
||||
for _, run := range runs {
|
||||
fmt.Fprintf(w, " %s status=%s events=%d last=%s", run.RunID, run.Status, run.Events, run.LastKind)
|
||||
if run.Checkpoint != "" {
|
||||
fmt.Fprintf(w, " checkpoint=%s", run.Checkpoint)
|
||||
}
|
||||
if run.Stage != "" {
|
||||
fmt.Fprintf(w, " stage=%s", run.Stage)
|
||||
}
|
||||
if run.LastError != "" {
|
||||
fmt.Fprintf(w, " error=%q", run.LastError)
|
||||
}
|
||||
@@ -98,25 +92,10 @@ func writeAgentInspection(w io.Writer, name string, runs []goagent.RunSummary, a
|
||||
fmt.Fprintf(w, " trace=%s", shortID(run.TraceID))
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
if isResumableAgentRun(run) {
|
||||
fmt.Fprintf(w, " resume: call micro.AgentResume(ctx, agent, %q) after recreating the agent with the same checkpoint store\n", run.RunID)
|
||||
}
|
||||
if run.Stage == "input-required" {
|
||||
fmt.Fprintf(w, " input: call micro.AgentResumeInput(ctx, agent, %q, input) to continue the paused run\n", run.RunID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isResumableAgentRun(run goagent.RunSummary) bool {
|
||||
switch run.Status {
|
||||
case "running", "error", "failed", "refused":
|
||||
return run.Checkpoint != "done" || run.Stage != ""
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func inspectFlow(c *cli.Context) error {
|
||||
name := c.Args().First()
|
||||
if name == "" {
|
||||
|
||||
@@ -11,27 +11,13 @@ import (
|
||||
)
|
||||
|
||||
func TestWriteAgentInspectionIncludesActionableBreadcrumbs(t *testing.T) {
|
||||
runs := []goagent.RunSummary{{RunID: "run-1", Status: "error", Events: 4, LastKind: "tool", LastError: "boom", TraceID: "1234567890abcdef", Checkpoint: "failed", Stage: "ask"}}
|
||||
runs := []goagent.RunSummary{{RunID: "run-1", Status: "error", Events: 4, LastKind: "tool", LastError: "boom", TraceID: "1234567890abcdef"}}
|
||||
var out bytes.Buffer
|
||||
if err := writeAgentInspection(&out, "support", runs, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"Agent \"support\" runs", "run-1", "status=error", "events=4", "last=tool", "checkpoint=failed", "stage=ask", `error="boom"`, "trace=1234567890ab", `micro.AgentResume(ctx, agent, "run-1")`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAgentInspectionIncludesInputResumeBreadcrumb(t *testing.T) {
|
||||
runs := []goagent.RunSummary{{RunID: "run-input", Status: "running", Events: 3, LastKind: "checkpoint", Checkpoint: "paused", Stage: "input-required"}}
|
||||
var out bytes.Buffer
|
||||
if err := writeAgentInspection(&out, "support", runs, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"checkpoint=paused", "stage=input-required", `micro.AgentResumeInput(ctx, agent, "run-input", input)`} {
|
||||
for _, want := range []string{"Agent \"support\" runs", "run-1", "status=error", "events=4", "last=tool", `error="boom"`, "trace=1234567890ab"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
|
||||
@@ -71,11 +71,10 @@ var dispatchRoles = map[string]dispatchRole{
|
||||
"planner": {"Loop: Planner", "Loop: planning review", "loop-planner", "planner-cron", "0 * * * *"},
|
||||
"builder": {"Loop: Builder", "Loop: build increment", "loop-builder", "builder-cron", "30 * * * *"},
|
||||
"coherence": {"Loop: Coherence", "Loop: coherence review", "loop-coherence", "coherence-cron", "0 7 * * *"},
|
||||
"security": {"Loop: Security", "Loop: security review", "loop-security", "security-cron", "0 6 * * 1"},
|
||||
}
|
||||
|
||||
// allRoles is the full set, in a stable order, for --roles=all and help text.
|
||||
var allRoles = []string{"planner", "builder", "triage", "coherence", "security", "release"}
|
||||
var allRoles = []string{"planner", "builder", "triage", "coherence", "release"}
|
||||
|
||||
const (
|
||||
promptDir = ".github/loop/prompts"
|
||||
@@ -96,7 +95,6 @@ Roles (choose with --roles, default: planner,builder,triage):
|
||||
builder builds the top open item as a single-concern PR (auto-merged on green CI)
|
||||
triage turns CI failures into scoped fix issues back into the queue
|
||||
coherence keeps README/docs/CHANGELOG aligned with the North Star
|
||||
security audits for vulnerabilities and files them (fixes stay human-reviewed)
|
||||
release cuts the next patch tag when the branch has new commits
|
||||
|
||||
Each dispatch role's instruction is an editable file in .github/loop/prompts/ —
|
||||
@@ -129,7 +127,6 @@ Examples:
|
||||
&cli.StringFlag{Name: "planner-cron", Usage: "Cron schedule for the planner", Value: "0 * * * *"},
|
||||
&cli.StringFlag{Name: "builder-cron", Usage: "Cron schedule for the builder", Value: "30 * * * *"},
|
||||
&cli.StringFlag{Name: "coherence-cron", Usage: "Cron schedule for the coherence role", Value: "0 7 * * *"},
|
||||
&cli.StringFlag{Name: "security-cron", Usage: "Cron schedule for the security role", Value: "0 6 * * 1"},
|
||||
&cli.StringFlag{Name: "release-cron", Usage: "Cron schedule for the release role", Value: "0 23 * * *"},
|
||||
&cli.StringFlag{Name: "tag-prefix", Usage: "Tag prefix the release role matches and bumps", Value: "v"},
|
||||
&cli.BoolFlag{Name: "force", Usage: "Overwrite existing loop files"},
|
||||
@@ -174,7 +171,6 @@ func runInit(c *cli.Context) error {
|
||||
"planner": c.String("planner-cron"),
|
||||
"builder": c.String("builder-cron"),
|
||||
"coherence": c.String("coherence-cron"),
|
||||
"security": c.String("security-cron"),
|
||||
}
|
||||
|
||||
if err := scaffold(dir, cfg, roles, crons, c.Bool("force")); err != nil {
|
||||
|
||||
@@ -29,7 +29,6 @@ func renderCases() map[string]config {
|
||||
"templates/prompts/planner.md.tmpl": testCfg,
|
||||
"templates/prompts/builder.md.tmpl": testCfg,
|
||||
"templates/prompts/coherence.md.tmpl": testCfg,
|
||||
"templates/prompts/security.md.tmpl": testCfg,
|
||||
}
|
||||
for role, d := range dispatchRoles {
|
||||
rc := testCfg
|
||||
@@ -60,7 +59,7 @@ func TestRenderIsPlaceholderFreeAndKeepsGHAExpressions(t *testing.T) {
|
||||
|
||||
func TestBaseBranchSubstitutedIntoPrompts(t *testing.T) {
|
||||
// The base branch appears in the PR-opening instructions of these prompts.
|
||||
for _, p := range []string{"planner", "builder", "coherence", "security"} {
|
||||
for _, p := range []string{"planner", "builder", "coherence"} {
|
||||
s := mustRender(t, "templates/prompts/"+p+".md.tmpl", testCfg)
|
||||
if !strings.Contains(s, "--base main") {
|
||||
t.Errorf("%s prompt missing substituted base branch", p)
|
||||
@@ -118,7 +117,7 @@ func TestDispatchWorkflowsStripPromptComments(t *testing.T) {
|
||||
|
||||
func TestPromptsLeaveRuntimeTokensLiteral(t *testing.T) {
|
||||
// __ISSUE__ must survive render (the workflow substitutes it at runtime).
|
||||
for _, p := range []string{"planner", "builder", "coherence", "triage", "security"} {
|
||||
for _, p := range []string{"planner", "builder", "coherence", "triage"} {
|
||||
s := mustRender(t, "templates/prompts/"+p+".md.tmpl", testCfg)
|
||||
if !strings.Contains(s, "__ISSUE__") {
|
||||
t.Errorf("%s prompt lost its __ISSUE__ runtime token", p)
|
||||
@@ -134,19 +133,19 @@ func TestScaffoldAllRolesWritesEverything(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, wfDir, "ci.yml"), "name: CI\n")
|
||||
|
||||
roles := []string{"planner", "builder", "triage", "coherence", "security", "release"}
|
||||
roles := []string{"planner", "builder", "triage", "coherence", "release"}
|
||||
if err := scaffold(dir, testCfg, roles, testCrons, false); err != nil {
|
||||
t.Fatalf("scaffold: %v", err)
|
||||
}
|
||||
|
||||
wantWorkflows := []string{"loop-planner.yml", "loop-builder.yml", "loop-triage.yml", "loop-coherence.yml", "loop-security.yml", "loop-release.yml"}
|
||||
wantWorkflows := []string{"loop-planner.yml", "loop-builder.yml", "loop-triage.yml", "loop-coherence.yml", "loop-release.yml"}
|
||||
for _, w := range wantWorkflows {
|
||||
if !fileExists(filepath.Join(dir, wfDir, w)) {
|
||||
t.Errorf("expected %s", w)
|
||||
}
|
||||
}
|
||||
// Dispatch + triage roles have prompts; release does not.
|
||||
for _, p := range []string{"planner.md", "builder.md", "triage.md", "coherence.md", "security.md"} {
|
||||
for _, p := range []string{"planner.md", "builder.md", "triage.md", "coherence.md"} {
|
||||
if !fileExists(filepath.Join(dir, promptDir, p)) {
|
||||
t.Errorf("expected prompt %s", p)
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<!--
|
||||
The SECURITY prompt — the editable policy for the security role. The workflow
|
||||
prepends the agent @mention and substitutes __ISSUE__ before posting. Keep
|
||||
__ISSUE__ literal.
|
||||
|
||||
Security is deliberately more conservative than the other roles: it does NOT
|
||||
auto-merge fixes, and it does NOT publish exploit details in public issues.
|
||||
-->
|
||||
Act as the security reviewer for this repository. Audit for real, exploitable vulnerabilities — do not pad the report with theoretical or low-value lint-style noise.
|
||||
|
||||
WHAT TO LOOK FOR: injection (SQL/command/template), authentication and authorization bypass, credential/secret/token exposure (in code, logs, or error messages), SSRF and unsafe outbound requests (especially user- or config-controlled URLs), path traversal, unsafe deserialization, missing or incorrect input validation on trust boundaries (HTTP handlers, RPC endpoints, message consumers), insecure defaults (TLS, auth, permissions), unsafe use of `crypto`/randomness, and known-vulnerable dependencies (run `govulncheck ./...` if available, or inspect `go.mod`).
|
||||
|
||||
DEDUPE against open issues before filing anything.
|
||||
|
||||
HOW TO REPORT — this matters:
|
||||
- **Known/public dependency CVEs** (already disclosed): file an issue labeled `security` referencing the CVE and the affected module, and you MAY open a PR that bumps the dependency to the patched version. Do **NOT** enable auto-merge — leave it for human review.
|
||||
- **Novel, exploitable vulnerabilities in this codebase** (not yet public): do **NOT** post a working exploit, proof-of-concept, or step-by-step reproduction in a public issue — that is irresponsible disclosure. File a CONCISE issue labeled `security` and `needs-human` that names the vulnerability *class*, the *location* (file/function), and the *impact*, with only enough detail for a maintainer to find it — and note it should be handled via the repository's private vulnerability reporting if the repo is public. Do NOT open a public fix PR that reveals the vulnerability; leave the fix to a human.
|
||||
- **Low-risk hardening** (defense-in-depth, missing validation with no proven exploit): a normal `security` issue is fine.
|
||||
|
||||
NEVER auto-merge a security change. Never weaken a control to make a test pass. Anything requiring an architectural or breaking change: label it `needs-human` and describe the tradeoff.
|
||||
|
||||
Post a summary as a comment on this issue (#__ISSUE__) — how many findings by severity, what you filed, and what needs a human — then close it (`gh issue close __ISSUE__`). If you open a dependency-bump PR, do it yourself from the shell: `git switch -c loop/security-__ISSUE__`, `git push -u origin loop/security-__ISSUE__`, `gh pr create --base << .DefaultBranch >> --title "<title>" --body "<summary, Closes #__ISSUE__>"` — then STOP; do NOT run `gh pr merge --auto`. Do not use a make_pr tool.
|
||||
@@ -1,8 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
microcmd "go-micro.dev/v6/cmd"
|
||||
@@ -21,7 +19,7 @@ func TestZeroToHeroCLIBoundaries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
for _, want := range []string{"run", "chat", "flow", "inspect", "deploy", "zero-to-hero"} {
|
||||
for _, want := range []string{"run", "chat", "flow", "inspect", "deploy"} {
|
||||
if !commands[want] {
|
||||
t.Fatalf("missing %q command", want)
|
||||
}
|
||||
@@ -50,29 +48,3 @@ func TestZeroToHeroCLIBoundaries(t *testing.T) {
|
||||
t.Fatal("missing deploy boundary: deploy --dry-run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroToHeroCommandPrintsMaintainedNoSecretPath(t *testing.T) {
|
||||
app := microcmd.DefaultCmd.App()
|
||||
var out bytes.Buffer
|
||||
oldWriter := app.Writer
|
||||
app.Writer = &out
|
||||
t.Cleanup(func() { app.Writer = oldWriter })
|
||||
|
||||
if err := app.Run([]string{"micro", "zero-to-hero"}); err != nil {
|
||||
t.Fatalf("micro zero-to-hero failed: %v", err)
|
||||
}
|
||||
|
||||
got := out.String()
|
||||
for _, want := range []string{
|
||||
"0→hero no-secret lifecycle demo",
|
||||
"./internal/harness/zero-to-hero-ci/run.sh",
|
||||
"go run ./examples/first-agent",
|
||||
"go run ./examples/support",
|
||||
"make harness",
|
||||
"services → agents → workflows",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("micro zero-to-hero output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-14
@@ -14,10 +14,8 @@ import (
|
||||
|
||||
type config struct {
|
||||
// the current values
|
||||
vals reader.Values
|
||||
exit chan bool
|
||||
closeMu sync.Mutex
|
||||
closed bool
|
||||
vals reader.Values
|
||||
exit chan bool
|
||||
// the current snapshot
|
||||
snap *loader.Snapshot
|
||||
opts Options
|
||||
@@ -50,9 +48,6 @@ func (c *config) Init(opts ...Option) error {
|
||||
Reader: json.NewReader(),
|
||||
}
|
||||
c.exit = make(chan bool)
|
||||
c.closeMu.Lock()
|
||||
c.closed = false
|
||||
c.closeMu.Unlock()
|
||||
for _, o := range opts {
|
||||
o(&c.opts)
|
||||
}
|
||||
@@ -189,15 +184,12 @@ func (c *config) Sync() error {
|
||||
}
|
||||
|
||||
func (c *config) Close() error {
|
||||
c.closeMu.Lock()
|
||||
defer c.closeMu.Unlock()
|
||||
|
||||
if c.closed {
|
||||
select {
|
||||
case <-c.exit:
|
||||
return nil
|
||||
default:
|
||||
close(c.exit)
|
||||
}
|
||||
|
||||
close(c.exit)
|
||||
c.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -46,30 +45,6 @@ func createFileForTest(t *testing.T) *os.File {
|
||||
return fh
|
||||
}
|
||||
|
||||
func TestConfigCloseConcurrentIdempotent(t *testing.T) {
|
||||
conf, err := NewConfig(WithWatcherDisabled())
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error but got %v", err)
|
||||
}
|
||||
|
||||
const goroutines = 64
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := conf.Close(); err != nil {
|
||||
t.Errorf("Expected close to be idempotent but got %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if err := conf.Close(); err != nil {
|
||||
t.Fatalf("Expected repeated close to be idempotent but got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigLoadWithGoodFile(t *testing.T) {
|
||||
fh := createFileForTest(t)
|
||||
path := fh.Name()
|
||||
|
||||
@@ -18,10 +18,8 @@ import (
|
||||
|
||||
type memory struct {
|
||||
// the current values
|
||||
vals reader.Values
|
||||
exit chan bool
|
||||
closeMu sync.Mutex
|
||||
closed bool
|
||||
vals reader.Values
|
||||
exit chan bool
|
||||
// the current snapshot
|
||||
snap *loader.Snapshot
|
||||
|
||||
@@ -272,15 +270,12 @@ func (m *memory) Sync() error {
|
||||
}
|
||||
|
||||
func (m *memory) Close() error {
|
||||
m.closeMu.Lock()
|
||||
defer m.closeMu.Unlock()
|
||||
|
||||
if m.closed {
|
||||
select {
|
||||
case <-m.exit:
|
||||
return nil
|
||||
default:
|
||||
close(m.exit)
|
||||
}
|
||||
|
||||
close(m.exit)
|
||||
m.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# Examples wayfinding
|
||||
|
||||
Use this index when you want the shortest path from a first runnable agent to the
|
||||
next services, agents, workflows, and interop examples. Every command below is
|
||||
provider-free unless the example README says otherwise.
|
||||
|
||||
## Pick by goal
|
||||
|
||||
| Goal | Start here | Run or verify | Then try |
|
||||
|------|------------|---------------|----------|
|
||||
| Run the smallest no-secret agent | [`first-agent`](./first-agent/) | `go run ./examples/first-agent` | [`agent-demo`](./agent-demo/) for a larger service-backed agent |
|
||||
| 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 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/) |
|
||||
|
||||
## Recommended adoption path
|
||||
|
||||
1. **First service:** run [`hello-world`](./hello-world/) to learn service
|
||||
registration, handlers, client calls, and health checks.
|
||||
2. **First agent:** run [`first-agent`](./first-agent/) with
|
||||
`go run ./examples/first-agent`; it uses a deterministic mock model and needs
|
||||
no provider key.
|
||||
3. **0→hero reference:** run [`support`](./support/) with
|
||||
`go run ./examples/support`; it keeps typed services, an agent chat loop, an
|
||||
event-driven flow, and an approval gate in one maintained example.
|
||||
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. **Workflow depth:** use [`flow-durable`](./flow-durable/) once the agent path
|
||||
needs checkpointed, resumable deterministic work.
|
||||
|
||||
## CLI wayfinding
|
||||
|
||||
The installed CLI prints the same path:
|
||||
|
||||
```bash
|
||||
micro examples
|
||||
micro agent demo
|
||||
micro zero-to-hero
|
||||
```
|
||||
|
||||
Keep this file, [`README.md`](../README.md), and the `micro examples` output in
|
||||
sync so new developers can find `examples/first-agent` and `examples/support`
|
||||
from one documented path.
|
||||
+5
-10
@@ -7,17 +7,15 @@ coordinate work with workflows.
|
||||
## Quick Start
|
||||
|
||||
Each example can be run with `go run .` from its directory unless its README says
|
||||
otherwise. If you are new to the repo, start with the [examples wayfinding index](./INDEX.md)
|
||||
or follow the first-agent path below instead of reading the directories alphabetically.
|
||||
otherwise. If you are new to the repo, follow the first-agent path below instead
|
||||
of reading the directories alphabetically.
|
||||
|
||||
## Recommended first-agent path
|
||||
|
||||
This path is the canonical services → agents → workflows route through the examples map. Debugging and observability wayfinding stays nearby once the first run works.
|
||||
|
||||
| Step | Start here | What you learn | Next step |
|
||||
|------|------------|----------------|-----------|
|
||||
| 1. First service | [`hello-world`](./hello-world/) | Build the 0→1 service path: create and register a basic RPC service, add a handler, call it with a client, and expose health checks. | Move to [`agent-demo`](./agent-demo/) to see services used by an agent. |
|
||||
| 2. First agent | [`first-agent`](./first-agent/) | Run the smallest service-backed agent with a deterministic mock model and no provider key. | Compare with [`agent-demo`](./agent-demo/) or the maintained 0-to-hero path in [`support`](./support/). |
|
||||
| 1. First service | [`hello-world`](./hello-world/) | Create and register a basic RPC service, add a handler, call it with a client, and expose health checks. | Move to [`agent-demo`](./agent-demo/) to see services used by an agent. |
|
||||
| 2. First agent | [`agent-demo`](./agent-demo/) | Run a small project-management app with Projects, Tasks, and Team services plus an agent playground. | Compare with the maintained 0-to-hero path in [`support`](./support/). |
|
||||
| 3. First workflow | [`support`](./support/) | Follow typed services into an agent chat loop, an event-driven `intake` flow, and an approval gate in one runnable reference. | Deepen the workflow model with [`flow-durable`](./flow-durable/). |
|
||||
|
||||
For the shortest AI-tooling bridge, the MCP path is
|
||||
@@ -77,11 +75,8 @@ Docker Compose deployment with MCP gateway, Consul registry, and Jaeger tracing:
|
||||
|
||||
### 2. Agents — turn services into tool-using teammates
|
||||
|
||||
#### [first-agent](./first-agent/)
|
||||
Smallest first agent: one notes service plus one scoped agent, backed by a deterministic mock model so `go run ./examples/first-agent` works without provider secrets.
|
||||
|
||||
#### [agent-demo](./agent-demo/)
|
||||
A multi-service project management app with Projects,
|
||||
Recommended first agent: a multi-service project management app with Projects,
|
||||
Tasks, and Team services, seed data, and agent playground integration.
|
||||
|
||||
#### [agent-plan-delegate](./agent-plan-delegate/)
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# First Agent
|
||||
|
||||
This is the smallest runnable service-backed agent in the repository. It sits
|
||||
between `micro new helloworld` and the full [`examples/support`](../support/)
|
||||
0→hero reference.
|
||||
|
||||
It runs with a deterministic mock model, so you do not need `ANTHROPIC_API_KEY`,
|
||||
`OPENAI_API_KEY`, or any other provider secret.
|
||||
|
||||
```bash
|
||||
go run ./examples/first-agent
|
||||
```
|
||||
|
||||
Expected transcript:
|
||||
|
||||
```text
|
||||
First agent (provider: mock, no API key)
|
||||
> Summarize my next steps
|
||||
[notes] listed starter notes
|
||||
assistant: Your first agent read the notes service and found three steps: install the CLI, run a service, then chat with an agent.
|
||||
✓ service-backed agent completed without provider secrets
|
||||
```
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
- `notes` is a normal Go Micro service with one RPC method.
|
||||
- `assistant` is an agent scoped to that service via `agent.Services("notes")`.
|
||||
- The mock model requests the service tool through the normal agent tool handler.
|
||||
- The final answer proves the service → agent path without a live model key.
|
||||
|
||||
CI keeps this path runnable with:
|
||||
|
||||
```bash
|
||||
go test ./examples/first-agent
|
||||
```
|
||||
|
||||
After this, continue to [`examples/support`](../support/) for the full services →
|
||||
agents → workflows lifecycle with a flow trigger and an approval gate.
|
||||
@@ -1,156 +0,0 @@
|
||||
// First Agent — the smallest runnable service-backed agent.
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// go run ./examples/first-agent
|
||||
//
|
||||
// It uses a deterministic mock model, so it needs no provider API key. The
|
||||
// point is to show the first agent shape: a service exposes a tool, an agent
|
||||
// discovers that service, the model asks to call the tool, and the agent returns
|
||||
// a final answer.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/broker"
|
||||
"go-micro.dev/v6/client"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/selector"
|
||||
"go-micro.dev/v6/service"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
type ListNotesRequest struct{}
|
||||
|
||||
type ListNotesResponse struct {
|
||||
Notes []string `json:"notes" description:"Notes the assistant can summarize"`
|
||||
}
|
||||
|
||||
type NotesService struct{}
|
||||
|
||||
// List returns the starter notes the first agent can read.
|
||||
// @example {}
|
||||
func (s *NotesService) List(ctx context.Context, req *ListNotesRequest, rsp *ListNotesResponse) error {
|
||||
rsp.Notes = []string{"Install the micro CLI", "Run a service", "Chat with an agent"}
|
||||
fmt.Println(" [notes] listed starter notes")
|
||||
return 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 "first-agent-mock" }
|
||||
func (m *mockModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("stream not supported by first-agent mock")
|
||||
}
|
||||
|
||||
func (m *mockModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) {
|
||||
for _, tool := range req.Tools {
|
||||
if strings.Contains(tool.Name, "List") && m.opts.ToolHandler != nil {
|
||||
m.opts.ToolHandler(ctx, ai.ToolCall{ID: "list-notes", Name: tool.Name, Input: map[string]any{}})
|
||||
break
|
||||
}
|
||||
}
|
||||
return &ai.Response{Answer: "Your first agent read the notes service and found three steps: install the CLI, run a service, then chat with an agent."}, nil
|
||||
}
|
||||
|
||||
func waitFor(reg registry.Registry, names ...string) error {
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for _, name := range names {
|
||||
for {
|
||||
if svcs, err := reg.GetService(name); err == nil && len(svcs) > 0 && len(svcs[0].Nodes) > 0 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("timed out waiting for %s", name)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runFirstAgent() error {
|
||||
ai.Register("first-agent-mock", newMock)
|
||||
|
||||
reg := registry.NewMemoryRegistry()
|
||||
br := broker.NewMemoryBroker()
|
||||
if err := br.Init(); err != nil {
|
||||
return fmt.Errorf("init broker: %w", err)
|
||||
}
|
||||
if err := br.Connect(); err != nil {
|
||||
return fmt.Errorf("connect broker: %w", err)
|
||||
}
|
||||
defer br.Disconnect()
|
||||
cl := client.NewClient(client.Registry(reg), client.Selector(selector.NewSelector(selector.Registry(reg))), client.Broker(br))
|
||||
|
||||
notes := service.New(service.Name("notes"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl), service.Broker(br), service.HandleSignal(false))
|
||||
if err := notes.Handle(new(NotesService)); err != nil {
|
||||
return fmt.Errorf("handle notes: %w", err)
|
||||
}
|
||||
svcErr := make(chan error, 1)
|
||||
go func() { svcErr <- notes.Run() }()
|
||||
defer notes.Server().Stop()
|
||||
|
||||
assistant := agent.New(
|
||||
agent.Name("assistant"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("notes"),
|
||||
agent.Prompt("You are a friendly first agent. Use the notes service before answering."),
|
||||
agent.Provider("first-agent-mock"),
|
||||
agent.WithRegistry(reg),
|
||||
agent.WithClient(cl),
|
||||
agent.WithBroker(br),
|
||||
agent.WithStore(store.NewMemoryStore()),
|
||||
)
|
||||
agentErr := make(chan error, 1)
|
||||
go func() { agentErr <- assistant.Run() }()
|
||||
defer assistant.Stop()
|
||||
|
||||
if err := waitFor(reg, "notes", "assistant"); err != nil {
|
||||
select {
|
||||
case runErr := <-svcErr:
|
||||
return fmt.Errorf("run notes: %w", runErr)
|
||||
case runErr := <-agentErr:
|
||||
return fmt.Errorf("run assistant: %w", runErr)
|
||||
default:
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("First agent (provider: mock, no API key)")
|
||||
fmt.Println("> Summarize my next steps")
|
||||
resp, err := assistant.Ask(context.Background(), "Summarize my next steps")
|
||||
if err != nil {
|
||||
return fmt.Errorf("ask assistant: %w", err)
|
||||
}
|
||||
fmt.Println("assistant:", resp.Reply)
|
||||
fmt.Println("✓ service-backed agent completed without provider secrets")
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := runFirstAgent(); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRunFirstAgent(t *testing.T) {
|
||||
if err := runFirstAgent(); err != nil {
|
||||
t.Fatalf("first-agent example failed: %v", err)
|
||||
}
|
||||
}
|
||||
+18
-22
@@ -201,13 +201,12 @@ type Part struct {
|
||||
|
||||
// Message is a turn in an A2A conversation.
|
||||
type Message struct {
|
||||
Role string `json:"role"` // "user" | "agent"
|
||||
Parts []Part `json:"parts"`
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
TaskID string `json:"taskId,omitempty"`
|
||||
ContextID string `json:"contextId,omitempty"`
|
||||
Kind string `json:"kind"` // "message"
|
||||
AP2Mandates []AP2SignedMandate `json:"ap2Mandates,omitempty"`
|
||||
Role string `json:"role"` // "user" | "agent"
|
||||
Parts []Part `json:"parts"`
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
TaskID string `json:"taskId,omitempty"`
|
||||
ContextID string `json:"contextId,omitempty"`
|
||||
Kind string `json:"kind"` // "message"
|
||||
}
|
||||
|
||||
// TaskStatus is a task's lifecycle state.
|
||||
@@ -224,14 +223,12 @@ type Artifact struct {
|
||||
|
||||
// Task is the unit of work returned by message/send and tasks/get.
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
ContextID string `json:"contextId"`
|
||||
Status TaskStatus `json:"status"`
|
||||
Artifacts []Artifact `json:"artifacts,omitempty"`
|
||||
History []Message `json:"history,omitempty"`
|
||||
Kind string `json:"kind"` // "task"
|
||||
AP2Mandates []AP2SignedMandate `json:"ap2Mandates,omitempty"`
|
||||
AP2Verifications []AP2Verification `json:"ap2Verifications,omitempty"`
|
||||
ID string `json:"id"`
|
||||
ContextID string `json:"contextId"`
|
||||
Status TaskStatus `json:"status"`
|
||||
Artifacts []Artifact `json:"artifacts,omitempty"`
|
||||
History []Message `json:"history,omitempty"`
|
||||
Kind string `json:"kind"` // "task"
|
||||
}
|
||||
|
||||
// PushNotificationConfig tells the gateway where to POST task updates for a
|
||||
@@ -851,13 +848,12 @@ func taskFromReplyWithIDsAndHistory(input Message, reply, state, taskID, context
|
||||
input.Kind = "message"
|
||||
}
|
||||
task := &Task{
|
||||
ID: taskID,
|
||||
ContextID: contextID,
|
||||
Kind: "task",
|
||||
History: append(append([]Message{}, history...), input),
|
||||
Status: TaskStatus{State: state, Timestamp: time.Now().UTC().Format(time.RFC3339)},
|
||||
Artifacts: []Artifact{textArtifact(reply)},
|
||||
AP2Mandates: append([]AP2SignedMandate{}, input.AP2Mandates...),
|
||||
ID: taskID,
|
||||
ContextID: contextID,
|
||||
Kind: "task",
|
||||
History: append(append([]Message{}, history...), input),
|
||||
Status: TaskStatus{State: state, Timestamp: time.Now().UTC().Format(time.RFC3339)},
|
||||
Artifacts: []Artifact{textArtifact(reply)},
|
||||
}
|
||||
task.History = append(task.History, Message{
|
||||
Role: "agent",
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
package a2a
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AP2MandateKind identifies the AP2 mandate stage represented by a credential.
|
||||
type AP2MandateKind string
|
||||
|
||||
const (
|
||||
AP2CheckoutMandate AP2MandateKind = "checkout"
|
||||
AP2PaymentMandate AP2MandateKind = "payment"
|
||||
)
|
||||
|
||||
// AP2RailRef names the settlement rail authorized by an AP2 payment mandate.
|
||||
type AP2RailRef struct {
|
||||
Type string `json:"type"`
|
||||
Reference string `json:"reference"`
|
||||
}
|
||||
|
||||
// AP2Mandate is a small verifiable AP2 credential. It is deliberately rail-neutral:
|
||||
// x402 is represented as one possible rail reference under a payment mandate.
|
||||
type AP2Mandate struct {
|
||||
ID string `json:"id"`
|
||||
Kind AP2MandateKind `json:"kind"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Merchant string `json:"merchant,omitempty"`
|
||||
Amount string `json:"amount,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
TaskID string `json:"taskId,omitempty"`
|
||||
ContextID string `json:"contextId,omitempty"`
|
||||
Rail *AP2RailRef `json:"rail,omitempty"`
|
||||
IssuedAt time.Time `json:"issuedAt"`
|
||||
}
|
||||
|
||||
// AP2SignedMandate is an AP2 mandate plus an Ed25519 signature over its canonical JSON.
|
||||
type AP2SignedMandate struct {
|
||||
Mandate AP2Mandate `json:"mandate"`
|
||||
KeyID string `json:"keyId,omitempty"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
// AP2Verification records mandate verification on an A2A task without mixing in
|
||||
// payment-settlement state.
|
||||
type AP2Verification struct {
|
||||
MandateID string `json:"mandateId"`
|
||||
Kind string `json:"kind"`
|
||||
Verified bool `json:"verified"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// NewAP2Keypair returns an Ed25519 keypair suitable for tests or local demos.
|
||||
func NewAP2Keypair() (ed25519.PublicKey, ed25519.PrivateKey, error) {
|
||||
return ed25519.GenerateKey(rand.Reader)
|
||||
}
|
||||
|
||||
// SignAP2Mandate signs a mandate as a verifiable AP2 credential.
|
||||
func SignAP2Mandate(m AP2Mandate, keyID string, private ed25519.PrivateKey) (AP2SignedMandate, error) {
|
||||
if m.ID == "" {
|
||||
return AP2SignedMandate{}, errors.New("ap2: mandate id is required")
|
||||
}
|
||||
if m.Kind == "" {
|
||||
return AP2SignedMandate{}, errors.New("ap2: mandate kind is required")
|
||||
}
|
||||
if m.IssuedAt.IsZero() {
|
||||
m.IssuedAt = time.Now().UTC()
|
||||
}
|
||||
payload, err := ap2Payload(m)
|
||||
if err != nil {
|
||||
return AP2SignedMandate{}, err
|
||||
}
|
||||
return AP2SignedMandate{Mandate: m, KeyID: keyID, Signature: base64.RawURLEncoding.EncodeToString(ed25519.Sign(private, payload))}, nil
|
||||
}
|
||||
|
||||
// VerifyAP2Mandate verifies a signed mandate credential.
|
||||
func VerifyAP2Mandate(s AP2SignedMandate, public ed25519.PublicKey) error {
|
||||
sig, err := base64.RawURLEncoding.DecodeString(s.Signature)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ap2: invalid signature encoding: %w", err)
|
||||
}
|
||||
payload, err := ap2Payload(s.Mandate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ed25519.Verify(public, payload, sig) {
|
||||
return errors.New("ap2: mandate signature verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AP2BindMandateToMessage returns a copy of m bound to the A2A message's task/context.
|
||||
func AP2BindMandateToMessage(m AP2Mandate, msg Message) AP2Mandate {
|
||||
m.TaskID = msg.TaskID
|
||||
m.ContextID = msg.ContextID
|
||||
return m
|
||||
}
|
||||
|
||||
// AP2AttachMandate returns a copy of msg carrying the signed AP2 mandate.
|
||||
func AP2AttachMandate(msg Message, mandate AP2SignedMandate) Message {
|
||||
msg.AP2Mandates = append(append([]AP2SignedMandate{}, msg.AP2Mandates...), mandate)
|
||||
return msg
|
||||
}
|
||||
|
||||
// VerifyAP2ForTask verifies signature, task binding, and optional settlement rail reference.
|
||||
func VerifyAP2ForTask(s AP2SignedMandate, public ed25519.PublicKey, task Task, rail *AP2RailRef) AP2Verification {
|
||||
out := AP2Verification{MandateID: s.Mandate.ID, Kind: string(s.Mandate.Kind), Verified: true}
|
||||
if err := VerifyAP2Mandate(s, public); err != nil {
|
||||
out.Verified = false
|
||||
out.Error = err.Error()
|
||||
return out
|
||||
}
|
||||
if s.Mandate.TaskID != "" && s.Mandate.TaskID != task.ID {
|
||||
out.Verified = false
|
||||
out.Error = "ap2: mandate task binding mismatch"
|
||||
return out
|
||||
}
|
||||
if s.Mandate.ContextID != "" && s.Mandate.ContextID != task.ContextID {
|
||||
out.Verified = false
|
||||
out.Error = "ap2: mandate context binding mismatch"
|
||||
return out
|
||||
}
|
||||
if s.Mandate.Kind == AP2PaymentMandate && rail != nil {
|
||||
if s.Mandate.Rail == nil || *s.Mandate.Rail != *rail {
|
||||
out.Verified = false
|
||||
out.Error = "ap2: settlement rail reference mismatch"
|
||||
return out
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// X402AP2Rail builds the x402 settlement rail reference carried under a payment mandate.
|
||||
func X402AP2Rail(reference string) AP2RailRef { return AP2RailRef{Type: "x402", Reference: reference} }
|
||||
|
||||
func ap2Payload(m AP2Mandate) ([]byte, error) {
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ap2: marshal mandate: %w", err)
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
return sum[:], nil
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package a2a
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testAP2Key(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
pub, priv, err := NewAP2Keypair()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pub, priv
|
||||
}
|
||||
|
||||
func TestAP2CheckoutMandateSignAttachAndVerify(t *testing.T) {
|
||||
pub, priv := testAP2Key(t)
|
||||
msg := Message{Role: "user", Kind: "message", TaskID: "task-1", ContextID: "ctx-1", Parts: []Part{{Kind: "text", Text: "buy"}}}
|
||||
mandate := AP2BindMandateToMessage(AP2Mandate{ID: "checkout-1", Kind: AP2CheckoutMandate, Subject: "alice", Merchant: "store", Amount: "10.00", Currency: "USD", Description: "demo", IssuedAt: time.Unix(1, 0).UTC()}, msg)
|
||||
signed, err := SignAP2Mandate(mandate, "test-key", priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msg = AP2AttachMandate(msg, signed)
|
||||
task := taskFromReplyWithIDs(msg, "ok", stateCompleted, msg.TaskID, msg.ContextID)
|
||||
|
||||
if len(task.AP2Mandates) != 1 {
|
||||
t.Fatalf("expected mandate carried on task, got %d", len(task.AP2Mandates))
|
||||
}
|
||||
got := VerifyAP2ForTask(task.AP2Mandates[0], pub, *task, nil)
|
||||
if !got.Verified || got.Error != "" {
|
||||
t.Fatalf("expected verified mandate, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAP2PaymentMandateX402RailReference(t *testing.T) {
|
||||
pub, priv := testAP2Key(t)
|
||||
rail := X402AP2Rail("payreq_123")
|
||||
task := Task{ID: "task-2", ContextID: "ctx-2"}
|
||||
signed, err := SignAP2Mandate(AP2Mandate{ID: "payment-1", Kind: AP2PaymentMandate, TaskID: task.ID, ContextID: task.ContextID, Rail: &rail, IssuedAt: time.Unix(1, 0).UTC()}, "test-key", priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := VerifyAP2ForTask(signed, pub, task, &rail)
|
||||
if !got.Verified {
|
||||
t.Fatalf("expected x402 rail to verify, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAP2TamperCasesFailDistinctly(t *testing.T) {
|
||||
pub, priv := testAP2Key(t)
|
||||
rail := X402AP2Rail("payreq_123")
|
||||
task := Task{ID: "task-3", ContextID: "ctx-3"}
|
||||
signed, err := SignAP2Mandate(AP2Mandate{ID: "payment-2", Kind: AP2PaymentMandate, TaskID: task.ID, ContextID: task.ContextID, Rail: &rail, IssuedAt: time.Unix(1, 0).UTC()}, "test-key", priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tampered := signed
|
||||
tampered.Mandate.Amount = "999.00"
|
||||
if got := VerifyAP2ForTask(tampered, pub, task, &rail); got.Verified || !strings.Contains(got.Error, "signature") {
|
||||
t.Fatalf("expected signature failure, got %+v", got)
|
||||
}
|
||||
|
||||
wrongTask := task
|
||||
wrongTask.ID = "other-task"
|
||||
if got := VerifyAP2ForTask(signed, pub, wrongTask, &rail); got.Verified || !strings.Contains(got.Error, "task binding") {
|
||||
t.Fatalf("expected task binding failure, got %+v", got)
|
||||
}
|
||||
|
||||
otherRail := X402AP2Rail("payreq_other")
|
||||
if got := VerifyAP2ForTask(signed, pub, task, &otherRail); got.Verified || !strings.Contains(got.Error, "rail reference") {
|
||||
t.Fatalf("expected rail reference failure, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -107,13 +107,12 @@ func (c *Client) SendMessage(ctx context.Context, message Message) (*Task, error
|
||||
return nil, err
|
||||
}
|
||||
return &Task{
|
||||
ID: m.TaskID,
|
||||
ContextID: m.ContextID,
|
||||
Kind: "task",
|
||||
Status: TaskStatus{State: stateCompleted, Timestamp: time.Now().UTC().Format(time.RFC3339)},
|
||||
Artifacts: []Artifact{textArtifact(textOf(m.Parts))},
|
||||
History: []Message{m},
|
||||
AP2Mandates: append([]AP2SignedMandate{}, m.AP2Mandates...),
|
||||
ID: m.TaskID,
|
||||
ContextID: m.ContextID,
|
||||
Kind: "task",
|
||||
Status: TaskStatus{State: stateCompleted, Timestamp: time.Now().UTC().Format(time.RFC3339)},
|
||||
Artifacts: []Artifact{textArtifact(textOf(m.Parts))},
|
||||
History: []Message{m},
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ contract.
|
||||
|
||||
## Scheduled CI
|
||||
|
||||
The hourly/manual `Harness (E2E)` workflow runs the same matrix with
|
||||
The daily/manual `Harness (E2E)` workflow runs the same matrix with
|
||||
`GO_MICRO_AGENT_CONFORMANCE_LIVE=1` and the provider secrets exported. Providers
|
||||
whose keys are absent still skip cleanly, while any configured provider must pass
|
||||
the shared tool-calling scenario. This keeps scheduled conformance key-gated: PR
|
||||
|
||||
@@ -24,7 +24,6 @@ Actions instead of subagents. Each role is a workflow:
|
||||
| **Evaluator** | `harness.yml` — *Harness (E2E)*, plus the CI gate (`tests.yaml`, `lint.yaml`) | Grades every change: the mock harness + unit/lint on each push/PR, and real-model conformance hourly. A *separate* grader — never the generator judging itself. |
|
||||
| **Evaluator → feedback** | `loop-triage.yml` — *Loop: Triage (Evaluator feedback)* | When a gate workflow (Lint, Run Tests, or the harness) fails on a non-PR run, root-causes, dedupes, and files scoped fix issues back into the planner's queue. The hill-climbing feedback path. |
|
||||
| **Coherence** | `loop-coherence.yml` — *Loop: Coherence* | Keeps README/website/docs/blog aligned with the North Star, keeps `CHANGELOG.md` living (reconciling `[Unreleased]` against merged PRs and rolling it into version headings as tags cut), and drafts the changelog blog post. |
|
||||
| **Security** | `loop-security.yml` — *Loop: Security* | Weekly vulnerability audit of the attack surface (MCP/A2A gateways, x402, auth, provider URLs, agent tool loop, deps via `govulncheck`). Files `security` issues; **never auto-merges** fixes and **never publishes exploit detail** in public issues (responsible disclosure); risky fixes are `needs-human`. |
|
||||
| **Release** | `loop-release.yml` — *Loop: Release (daily patch)* | Cuts a daily patch tag when master has new commits, so the *installable* framework tracks the loop's improvements (triggers `release.yml`/goreleaser). Minor/major bumps stay with the human. |
|
||||
|
||||
Generation is separated from evaluation on purpose: an agent grading its own work
|
||||
|
||||
@@ -49,34 +49,6 @@ require_output() {
|
||||
fi
|
||||
}
|
||||
|
||||
require_ordered_output() {
|
||||
local description=$1
|
||||
shift
|
||||
local -a expected=()
|
||||
while [[ $# -gt 0 && "$1" != "--" ]]; do
|
||||
expected+=("$1")
|
||||
shift
|
||||
done
|
||||
shift
|
||||
|
||||
local output
|
||||
if ! output=$("$MICRO" "$@" 2>&1); then
|
||||
echo "micro $* failed while checking $description" >&2
|
||||
echo "$output" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local remainder=$output
|
||||
for text in "${expected[@]}"; do
|
||||
if [[ "$remainder" != *"$text"* ]]; then
|
||||
echo "micro $* missing expected ordered text '$text' for $description" >&2
|
||||
echo "$output" >&2
|
||||
exit 1
|
||||
fi
|
||||
remainder=${remainder#*"$text"}
|
||||
done
|
||||
}
|
||||
|
||||
require_output "version" "micro version" --version
|
||||
require_output "root help" "COMMANDS" --help
|
||||
require_output "service scaffold" "micro new" new --help
|
||||
@@ -86,51 +58,4 @@ require_output "agent chat" "micro chat" chat --help
|
||||
require_output "agent inspection" "micro inspect agent" inspect agent --help
|
||||
require_output "flow inspection" "micro inspect flow" inspect flow --help
|
||||
|
||||
require_ordered_output "installed first-agent docs wayfinding" \
|
||||
"micro agent demo" \
|
||||
"no-secret-first-agent.html" \
|
||||
"your-first-agent.html" \
|
||||
"micro agent preflight # before micro run: prerequisites" \
|
||||
"micro run" \
|
||||
"micro chat" \
|
||||
"micro agent doctor # after micro run: chat/gateway/inspect recovery" \
|
||||
"debugging-agents.html" \
|
||||
"micro inspect agent <name>" \
|
||||
"zero-to-hero.html" \
|
||||
-- docs
|
||||
|
||||
require_ordered_output "installed provider-free examples wayfinding" \
|
||||
"go run ./examples/first-agent" \
|
||||
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1" \
|
||||
"go run ./examples/support" \
|
||||
"micro agent demo" \
|
||||
"micro docs" \
|
||||
"micro zero-to-hero" \
|
||||
"no-secret-first-agent.html" \
|
||||
"your-first-agent.html" \
|
||||
"debugging-agents.html" \
|
||||
"zero-to-hero.html" \
|
||||
-- examples
|
||||
|
||||
require_ordered_output "installed no-secret agent demo" \
|
||||
"provider-free" \
|
||||
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1" \
|
||||
"your-first-agent.html" \
|
||||
"debugging-agents.html" \
|
||||
"zero-to-hero.html" \
|
||||
"micro agent preflight # before micro run: prerequisites" \
|
||||
"micro run" \
|
||||
"micro chat" \
|
||||
"micro agent doctor # after micro run: chat/gateway/inspect recovery" \
|
||||
"micro inspect agent <name>" \
|
||||
-- agent demo
|
||||
|
||||
require_ordered_output "installed zero-to-hero lifecycle wayfinding" \
|
||||
"./internal/harness/zero-to-hero-ci/run.sh" \
|
||||
"go run ./examples/first-agent" \
|
||||
"go run ./examples/support" \
|
||||
"make harness" \
|
||||
"zero-to-hero.html" \
|
||||
-- zero-to-hero
|
||||
|
||||
echo "✓ install smoke path verified"
|
||||
|
||||
@@ -131,8 +131,6 @@ const delegatedNotifyTask = "Use the notify Send tool exactly once to tell owner
|
||||
|
||||
const commsPrompt = "You handle outbound notifications. When asked to notify someone, you must call the notify Send tool exactly once before replying. Never claim a notification was sent unless the notify tool returned success."
|
||||
|
||||
const delegatedNotifySettleTimeout = 10 * time.Second
|
||||
|
||||
type SendRequest struct {
|
||||
To string `json:"to" description:"Recipient address"`
|
||||
Message string `json:"message" description:"Message body"`
|
||||
@@ -157,7 +155,7 @@ func (s *NotifyService) Send(ctx context.Context, req *SendRequest, rsp *SendRes
|
||||
if s.bySend == nil {
|
||||
s.bySend = map[string]bool{}
|
||||
}
|
||||
key := notifyDedupKey(req.To, req.Message)
|
||||
key := strings.ToLower(strings.TrimSpace(req.To)) + "\x00" + strings.ToLower(strings.TrimSpace(req.Message))
|
||||
s.attempts++
|
||||
if !s.bySend[key] {
|
||||
s.bySend[key] = true
|
||||
@@ -184,51 +182,6 @@ func (s *NotifyService) duplicateAttempts() int {
|
||||
return s.duplicates
|
||||
}
|
||||
|
||||
func notifyDedupKey(to, message string) string {
|
||||
recipient := canonicalLaunchNotifyRecipient(normalizeNotifyText(to))
|
||||
body := normalizeNotifyText(message)
|
||||
if isLaunchReadinessNotify(body) {
|
||||
body = "launch-readiness"
|
||||
}
|
||||
return recipient + "\x00" + body
|
||||
}
|
||||
|
||||
func canonicalLaunchNotifyRecipient(recipient string) string {
|
||||
switch recipient {
|
||||
case "owner", "launch owner", "plan owner", "owner acme com", "owner@acme com", "owner @ acme com":
|
||||
return "owner@acme.com"
|
||||
default:
|
||||
if strings.Contains(recipient, "owner") && strings.Contains(recipient, "acme") {
|
||||
return "owner@acme.com"
|
||||
}
|
||||
return recipient
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeNotifyText(message string) string {
|
||||
message = strings.ToLower(strings.TrimSpace(message))
|
||||
message = strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
return r
|
||||
case r == '@':
|
||||
return r
|
||||
default:
|
||||
return ' '
|
||||
}
|
||||
}, message)
|
||||
return strings.Join(strings.Fields(message), " ")
|
||||
}
|
||||
|
||||
func isLaunchReadinessNotify(message string) bool {
|
||||
return strings.Contains(message, "launch") &&
|
||||
strings.Contains(message, "plan") &&
|
||||
(strings.Contains(message, "ready") ||
|
||||
strings.Contains(message, "readiness") ||
|
||||
strings.Contains(message, "prepared") ||
|
||||
strings.Contains(message, "complete"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mock LLM provider — the ONLY fake. It "reasons" by simple heuristics
|
||||
// over the tools it's offered and the system prompt it's given, calling
|
||||
@@ -244,15 +197,6 @@ type mockModel struct {
|
||||
// still keeping the regression deterministic and keyless.
|
||||
unknownDelegateOnce bool
|
||||
emittedUnknownDelegate bool
|
||||
|
||||
// duplicateNotify makes the comms mock replay the same notification call.
|
||||
// The notify service should collapse that replay to one durable side effect.
|
||||
duplicateNotify bool
|
||||
|
||||
// duplicateDelegate makes the conductor mock replay the same delegate call.
|
||||
// The delegate idempotency path should collapse that replay before it can
|
||||
// ask the delegated comms agent to notify twice.
|
||||
duplicateDelegate bool
|
||||
}
|
||||
|
||||
func newMock(opts ...ai.Option) ai.Model {
|
||||
@@ -267,18 +211,6 @@ func newMockUnknownDelegate(opts ...ai.Option) ai.Model {
|
||||
return m
|
||||
}
|
||||
|
||||
func newMockDuplicateNotify(opts ...ai.Option) ai.Model {
|
||||
m := &mockModel{duplicateNotify: true}
|
||||
_ = m.Init(opts...)
|
||||
return m
|
||||
}
|
||||
|
||||
func newMockDuplicateDelegate(opts ...ai.Option) ai.Model {
|
||||
m := &mockModel{duplicateDelegate: true}
|
||||
_ = m.Init(opts...)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *mockModel) Init(opts ...ai.Option) error {
|
||||
for _, o := range opts {
|
||||
o(&m.opts)
|
||||
@@ -320,14 +252,10 @@ func (m *mockModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.Gener
|
||||
// comms agent: owns notify, has Send but not Add.
|
||||
case hasSend && !hasAdd:
|
||||
send := findTool(req.Tools, "Send")
|
||||
input := map[string]any{
|
||||
m.call("comms", send, map[string]any{
|
||||
"to": "owner@acme.com",
|
||||
"message": "The launch plan is ready",
|
||||
}
|
||||
m.call("comms", send, input)
|
||||
if m.duplicateNotify {
|
||||
m.call("comms", send, input)
|
||||
}
|
||||
})
|
||||
return &ai.Response{Answer: "Notified owner@acme.com."}, nil
|
||||
|
||||
// conductor: has the task Add tool — plan, create tasks, delegate.
|
||||
@@ -355,14 +283,10 @@ func (m *mockModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.Gener
|
||||
"to": "comms",
|
||||
})
|
||||
} else {
|
||||
input := map[string]any{
|
||||
m.call("conductor", del, map[string]any{
|
||||
"task": delegatedNotifyTask,
|
||||
"to": "comms",
|
||||
}
|
||||
m.call("conductor", del, input)
|
||||
if m.duplicateDelegate {
|
||||
m.call("conductor", del, input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
return &ai.Response{Answer: "Created Design, Build and Ship, and had comms notify the owner."}, nil
|
||||
@@ -396,10 +320,6 @@ func runPlanDelegate(provider string) error {
|
||||
ai.Register("mock", newMock)
|
||||
case "mock-unknown-delegate":
|
||||
ai.Register("mock-unknown-delegate", newMockUnknownDelegate)
|
||||
case "mock-duplicate-notify":
|
||||
ai.Register("mock-duplicate-notify", newMockDuplicateNotify)
|
||||
case "mock-duplicate-delegate":
|
||||
ai.Register("mock-duplicate-delegate", newMockDuplicateDelegate)
|
||||
default:
|
||||
apiKey = providerKey(provider)
|
||||
if apiKey == "" {
|
||||
@@ -481,14 +401,8 @@ func runPlanDelegate(provider string) error {
|
||||
}
|
||||
|
||||
f := flow.New("zero-to-hero",
|
||||
flow.Steps(
|
||||
flow.Step{Name: "conductor", Run: planDelegateConductorStep(conductor)},
|
||||
flow.Step{Name: "require-notify", Run: requireDelegatedNotifyStep(taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
_, err := conductor.Ask(ctx, "The Design, Build, and Ship tasks already exist, but the owner notification is still missing. Delegate exactly one notification to the \"comms\" agent now with this exact subtask: "+delegatedNotifyTask+" Do not create more tasks and do not answer until comms has handled the notification.")
|
||||
return err
|
||||
})},
|
||||
),
|
||||
flow.WithCheckpoint(flow.StoreCheckpoint(mem, "flow-zero-to-hero")),
|
||||
flow.Agent("conductor"),
|
||||
flow.Prompt("Create three launch tasks (Design, Build, Ship), then make sure owner@acme.com is notified: {{.Data}}"),
|
||||
flow.Timeout(harnessutil.LiveTimeout(provider)),
|
||||
)
|
||||
if err := f.Register(reg, broker.DefaultBroker, cl); err != nil {
|
||||
@@ -503,8 +417,15 @@ func runPlanDelegate(provider string) error {
|
||||
executeDone <- f.Execute(ctx, "launch readiness")
|
||||
}()
|
||||
|
||||
if err := waitForPlanDelegateExecution(executeDone, taskSvc, notifySvc); err != nil {
|
||||
if err := waitForPlanDelegateExecution(executeDone, taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
_, err := conductor.Ask(ctx, "The Design, Build, and Ship tasks already exist, but the owner notification is still missing. Delegate exactly one notification to the \"comms\" agent now with this exact subtask: "+delegatedNotifyTask+" Do not create more tasks and do not answer until comms has handled the notification.")
|
||||
return err
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rs := f.Results(); len(rs) > 0 {
|
||||
fmt.Println("\n\033[1m< conductor reply:\033[0m", rs[len(rs)-1].Reply)
|
||||
}
|
||||
|
||||
// Prove plan was persisted to the real store.
|
||||
@@ -521,48 +442,7 @@ func runPlanDelegate(provider string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func planDelegateConductorStep(conductor agent.Agent) flow.StepFunc {
|
||||
return func(ctx context.Context, in flow.State) (flow.State, error) {
|
||||
prompt := "Create three launch tasks (Design, Build, Ship), then make sure owner@acme.com is notified: " + in.String()
|
||||
rsp, err := conductor.Ask(ctx, prompt)
|
||||
if err != nil {
|
||||
return in, err
|
||||
}
|
||||
if rsp != nil && rsp.Reply != "" {
|
||||
fmt.Println("\n\033[1m< conductor reply:\033[0m", rsp.Reply)
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
}
|
||||
|
||||
func requireDelegatedNotifyStep(taskSvc *TaskService, notifySvc *NotifyService, recoverMissingNotify func(context.Context) error) flow.StepFunc {
|
||||
return func(ctx context.Context, in flow.State) (flow.State, error) {
|
||||
tasks := taskSvc.count()
|
||||
notify := notifySvc.count()
|
||||
if notify == 1 {
|
||||
return in, nil
|
||||
}
|
||||
if recoverMissingNotify == nil || tasks != 3 || notify != 0 {
|
||||
return in, fmt.Errorf("delegation completed without required notify side effect: notify=%d, want 1", notify)
|
||||
}
|
||||
settled, err := waitForNotifySideEffect(notifySvc, delegatedNotifySettleTimeout)
|
||||
if err != nil {
|
||||
return in, err
|
||||
}
|
||||
if !settled {
|
||||
fmt.Print("\n\033[33mwarning:\033[0m conductor step completed before delegated notify; retrying the missing comms handoff once before the flow can complete.\n")
|
||||
if err := recoverMissingNotify(ctx); err != nil {
|
||||
return in, fmt.Errorf("delegation completed without required notify side effect and recovery failed: notify=%d, want 1: %w", notify, err)
|
||||
}
|
||||
}
|
||||
if notify = notifySvc.count(); notify != 1 {
|
||||
return in, fmt.Errorf("delegation recovery completed without required notify side effect: notify=%d, want 1", notify)
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
}
|
||||
|
||||
func waitForPlanDelegateExecution(done <-chan error, taskSvc *TaskService, notifySvc *NotifyService) error {
|
||||
func waitForPlanDelegateExecution(done <-chan error, taskSvc *TaskService, notifySvc *NotifyService, recoverMissingNotify func(context.Context) error) error {
|
||||
ticker := time.NewTicker(50 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
@@ -581,12 +461,30 @@ func waitForPlanDelegateExecution(done <-chan error, taskSvc *TaskService, notif
|
||||
return fmt.Errorf("flow execute after side effects tasks=%d notify=%d: %w", tasks, notify, err)
|
||||
}
|
||||
if notify != 1 {
|
||||
return fmt.Errorf("delegation completed without required notify side effect: notify=%d, want 1", notify)
|
||||
if recoverMissingNotify == nil || tasks != 3 || notify != 0 {
|
||||
return fmt.Errorf("delegation completed without required notify side effect: notify=%d, want 1", notify)
|
||||
}
|
||||
settled, err := waitForNotifySideEffect(notifySvc, 2*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !settled {
|
||||
fmt.Print("\n\033[33mwarning:\033[0m flow completed before delegated notify; retrying the missing comms handoff once.\n")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
retryErr := recoverMissingNotify(ctx)
|
||||
cancel()
|
||||
if retryErr != nil {
|
||||
return fmt.Errorf("delegation completed without required notify side effect and recovery failed: notify=%d, want 1: %w", notify, retryErr)
|
||||
}
|
||||
}
|
||||
if notify = notifySvc.count(); notify != 1 {
|
||||
return fmt.Errorf("delegation recovery completed without required notify side effect: notify=%d, want 1", notify)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
if notifySvc.count() == 1 {
|
||||
continue
|
||||
if dup := notifySvc.duplicateAttempts(); dup > 0 {
|
||||
return fmt.Errorf("duplicate notify attempts: got %d duplicate replay(s), want 0", dup)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -598,6 +496,9 @@ func waitForNotifySideEffect(notifySvc *NotifyService, timeout time.Duration) (b
|
||||
if notifySvc.count() == 1 {
|
||||
return true, nil
|
||||
}
|
||||
if dup := notifySvc.duplicateAttempts(); dup > 0 {
|
||||
return false, fmt.Errorf("duplicate notify attempts: got %d duplicate replay(s), want 0", dup)
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return false, nil
|
||||
}
|
||||
@@ -615,7 +516,7 @@ func isClientTimeout(err error) bool {
|
||||
}
|
||||
|
||||
func main() {
|
||||
provider := flag.String("provider", "mock", "LLM provider: mock (default), mock-unknown-delegate, mock-duplicate-notify, mock-duplicate-delegate, anthropic, openai, gemini, groq, mistral, together, atlascloud")
|
||||
provider := flag.String("provider", "mock", "LLM provider: mock (default), mock-unknown-delegate, anthropic, openai, gemini, groq, mistral, together, atlascloud")
|
||||
flag.Parse()
|
||||
|
||||
if err := runPlanDelegate(*provider); err != nil {
|
||||
|
||||
@@ -231,24 +231,6 @@ func TestPlanDelegateRetriesAfterUnknownDelegateTool(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDelegateIdempotentDuplicateNotifyReplay(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("0→hero harness boots an end-to-end system; skipped with -short")
|
||||
}
|
||||
if err := runPlanDelegate("mock-duplicate-notify"); err != nil {
|
||||
t.Fatalf("0→hero harness with duplicate notify replay: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDelegateIdempotentDuplicateDelegateReplay(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("0→hero harness boots an end-to-end system; skipped with -short")
|
||||
}
|
||||
if err := runPlanDelegate("mock-duplicate-delegate"); err != nil {
|
||||
t.Fatalf("0→hero harness with duplicate delegate replay: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskServiceAddIsIdempotentForLaunchTitles(t *testing.T) {
|
||||
svc := new(TaskService)
|
||||
for _, title := range []string{"Design", "design task", "Build", "Build launch task", "Ship", "ship readiness"} {
|
||||
@@ -265,7 +247,7 @@ func TestTaskServiceAddIsIdempotentForLaunchTitles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDelegateExecutionAcceptsDuplicateNotifyReplay(t *testing.T) {
|
||||
func TestPlanDelegateExecutionReportsDuplicateNotifyBeforeTimeout(t *testing.T) {
|
||||
notifySvc := new(NotifyService)
|
||||
for i := 0; i < 2; i++ {
|
||||
var rsp SendResponse
|
||||
@@ -274,16 +256,20 @@ func TestPlanDelegateExecutionAcceptsDuplicateNotifyReplay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
done <- nil
|
||||
if err := waitForPlanDelegateExecution(done, new(TaskService), notifySvc); err != nil {
|
||||
t.Fatalf("waitForPlanDelegateExecution returned %v, want duplicate replay accepted", err)
|
||||
}
|
||||
if got := notifySvc.count(); got != 1 {
|
||||
t.Fatalf("notify count = %d, want 1 after duplicate replay", got)
|
||||
}
|
||||
if got := notifySvc.duplicateAttempts(); got != 1 {
|
||||
t.Fatalf("duplicate attempts = %d, want 1 recorded replay", got)
|
||||
done := make(chan error)
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- waitForPlanDelegateExecution(done, new(TaskService), notifySvc, nil) }()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err == nil {
|
||||
t.Fatal("waitForPlanDelegateExecution returned nil, want duplicate notify error")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "duplicate notify attempts") {
|
||||
t.Fatalf("error = %q, want duplicate notify attempts", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("waitForPlanDelegateExecution did not report duplicate notify before timeout")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,7 +278,7 @@ func TestPlanDelegateExecutionRejectsClaimedCompletionWithoutNotify(t *testing.T
|
||||
done := make(chan error, 1)
|
||||
done <- nil
|
||||
|
||||
err := waitForPlanDelegateExecution(done, new(TaskService), notifySvc)
|
||||
err := waitForPlanDelegateExecution(done, new(TaskService), notifySvc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("waitForPlanDelegateExecution returned nil, want missing notify side-effect error")
|
||||
}
|
||||
@@ -310,13 +296,15 @@ func TestPlanDelegateExecutionRecoversMissingNotifyOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
notifySvc := new(NotifyService)
|
||||
done := make(chan error, 1)
|
||||
done <- nil
|
||||
|
||||
recovered := false
|
||||
_, err := requireDelegatedNotifyStep(taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
err := waitForPlanDelegateExecution(done, taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
recovered = true
|
||||
var rsp SendResponse
|
||||
return notifySvc.Send(ctx, &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp)
|
||||
})(context.Background(), flow.State{})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForPlanDelegateExecution returned %v, want recovery success", err)
|
||||
}
|
||||
@@ -337,6 +325,8 @@ func TestPlanDelegateExecutionWaitsForInFlightNotifyAfterFlowCompletion(t *testi
|
||||
}
|
||||
}
|
||||
notifySvc := new(NotifyService)
|
||||
done := make(chan error, 1)
|
||||
done <- nil
|
||||
|
||||
go func() {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
@@ -345,11 +335,11 @@ func TestPlanDelegateExecutionWaitsForInFlightNotifyAfterFlowCompletion(t *testi
|
||||
}()
|
||||
|
||||
recovered := false
|
||||
_, err := requireDelegatedNotifyStep(taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
err := waitForPlanDelegateExecution(done, taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
recovered = true
|
||||
var rsp SendResponse
|
||||
return notifySvc.Send(ctx, &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp)
|
||||
})(context.Background(), flow.State{})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForPlanDelegateExecution returned %v, want in-flight notify success", err)
|
||||
}
|
||||
@@ -384,7 +374,7 @@ func TestPlanDelegateExecutionAcceptsClientTimeoutAfterSideEffects(t *testing.T)
|
||||
done := make(chan error, 1)
|
||||
done <- errors.New(`{"id":"go.micro.client","code":408,"detail":"<nil>","status":"Request Timeout"}`)
|
||||
|
||||
if err := waitForPlanDelegateExecution(done, taskSvc, notifySvc); err != nil {
|
||||
if err := waitForPlanDelegateExecution(done, taskSvc, notifySvc, nil); err != nil {
|
||||
t.Fatalf("waitForPlanDelegateExecution returned %v, want completed side effects to satisfy client timeout", err)
|
||||
}
|
||||
}
|
||||
@@ -393,7 +383,7 @@ func TestPlanDelegateExecutionClassifiesClientTimeoutBeforeSideEffects(t *testin
|
||||
done := make(chan error, 1)
|
||||
done <- errors.New(`{"id":"go.micro.client","code":408,"detail":"<nil>","status":"Request Timeout"}`)
|
||||
|
||||
err := waitForPlanDelegateExecution(done, new(TaskService), new(NotifyService))
|
||||
err := waitForPlanDelegateExecution(done, new(TaskService), new(NotifyService), nil)
|
||||
if err == nil {
|
||||
t.Fatal("waitForPlanDelegateExecution returned nil, want timeout before side effects to fail")
|
||||
}
|
||||
@@ -420,7 +410,7 @@ func TestPlanDelegateExecutionClassifiesPartialClientTimeout(t *testing.T) {
|
||||
done := make(chan error, 1)
|
||||
done <- errors.New(`{"id":"go.micro.client","code":408,"detail":"<nil>","status":"Request Timeout"}`)
|
||||
|
||||
err := waitForPlanDelegateExecution(done, taskSvc, new(NotifyService))
|
||||
err := waitForPlanDelegateExecution(done, taskSvc, new(NotifyService), nil)
|
||||
if err == nil {
|
||||
t.Fatal("waitForPlanDelegateExecution returned nil, want timeout before notify to fail")
|
||||
}
|
||||
@@ -431,18 +421,9 @@ func TestPlanDelegateExecutionClassifiesPartialClientTimeout(t *testing.T) {
|
||||
|
||||
func TestNotifyServiceSendIsIdempotentForDuplicateDelivery(t *testing.T) {
|
||||
svc := new(NotifyService)
|
||||
messages := []string{
|
||||
"The launch plan is ready",
|
||||
"The launch plan is ready.",
|
||||
"Launch readiness: the plan is ready!",
|
||||
}
|
||||
for i, message := range messages {
|
||||
for i := 0; i < 3; i++ {
|
||||
var rsp SendResponse
|
||||
to := "owner@acme.com"
|
||||
if i == len(messages)-1 {
|
||||
to = "owner"
|
||||
}
|
||||
if err := svc.Send(context.Background(), &SendRequest{To: to, Message: message}, &rsp); err != nil {
|
||||
if err := svc.Send(context.Background(), &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp); err != nil {
|
||||
t.Fatalf("Send attempt %d: %v", i+1, err)
|
||||
}
|
||||
if !rsp.Sent {
|
||||
@@ -452,32 +433,4 @@ func TestNotifyServiceSendIsIdempotentForDuplicateDelivery(t *testing.T) {
|
||||
if got := svc.count(); got != 1 {
|
||||
t.Fatalf("notify count = %d, want 1 after duplicate delivery replays", got)
|
||||
}
|
||||
if got := svc.duplicateAttempts(); got != len(messages)-1 {
|
||||
t.Fatalf("duplicate notify attempts = %d, want %d", got, len(messages)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyServiceCollapsesProviderReadinessParaphrases(t *testing.T) {
|
||||
svc := new(NotifyService)
|
||||
requests := []SendRequest{
|
||||
{To: "owner@acme.com", Message: "The launch plan is ready"},
|
||||
{To: "owner @ acme.com", Message: "Launch plan ready."},
|
||||
{To: "launch owner", Message: "The launch readiness plan is prepared."},
|
||||
{To: "plan owner", Message: "Launch plan is complete!"},
|
||||
}
|
||||
for i, req := range requests {
|
||||
var rsp SendResponse
|
||||
if err := svc.Send(context.Background(), &req, &rsp); err != nil {
|
||||
t.Fatalf("Send attempt %d: %v", i+1, err)
|
||||
}
|
||||
if !rsp.Sent {
|
||||
t.Fatalf("Send attempt %d reported Sent=false", i+1)
|
||||
}
|
||||
}
|
||||
if got := svc.count(); got != 1 {
|
||||
t.Fatalf("notify count = %d, want 1 after provider paraphrase replays", got)
|
||||
}
|
||||
if got := svc.duplicateAttempts(); got != len(requests)-1 {
|
||||
t.Fatalf("duplicate notify attempts = %d, want %d", got, len(requests)-1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ go run ./internal/harness/provider-conformance \
|
||||
## Scheduled CI behavior
|
||||
|
||||
The `Harness (E2E)` workflow runs on pushes and pull requests with deterministic
|
||||
mock LLMs, including `provider-conformance -providers mock`. On the hourly
|
||||
mock LLMs, including `provider-conformance -providers mock`. On the daily
|
||||
schedule and manual dispatch it also runs the live provider conformance job. A
|
||||
manual dispatch can narrow `providers` or `harnesses`, and can set
|
||||
`require_configured=true` to fail fast when an expected repository secret is
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHarnessWorkflowSchedulesLiveProviderMatrix(t *testing.T) {
|
||||
path := filepath.Join(repoRoot(), ".github", "workflows", "harness.yml")
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read harness workflow: %v", err)
|
||||
}
|
||||
workflow := string(b)
|
||||
|
||||
checks := []string{
|
||||
`name: Harness (E2E)`,
|
||||
`schedule:`,
|
||||
`cron: "17 * * * *"`,
|
||||
`workflow_dispatch:`,
|
||||
`harness-live:`,
|
||||
`if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'`,
|
||||
`ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}`,
|
||||
`OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}`,
|
||||
`GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}`,
|
||||
`GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}`,
|
||||
`MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}`,
|
||||
`MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}`,
|
||||
`TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}`,
|
||||
`ATLASCLOUD_API_KEY: ${{ secrets.ATLASCLOUD_API_KEY }}`,
|
||||
`-summary-json provider-conformance-summary.json`,
|
||||
`-summary-markdown provider-conformance-summary.md`,
|
||||
`-capabilities-markdown provider-capabilities.md`,
|
||||
`actions/upload-artifact@v4`,
|
||||
}
|
||||
for _, want := range checks {
|
||||
if !strings.Contains(workflow, want) {
|
||||
t.Fatalf("harness workflow missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,9 @@ scripted so CI can run it on every push without external services or model keys.
|
||||
documented first-agent walkthrough path.
|
||||
2. **Run** — `micro run` remains available as the local development entry point.
|
||||
3. **Chat** — `micro chat` remains available as the interactive agent entry point.
|
||||
4. **Inspect/debugging** — `micro inspect agent <name>`, `micro agent history <name>`,
|
||||
and `micro inspect flow <name>` remain available as the local run-history
|
||||
inspection step. The no-secret debugging smoke seeds durable agent run history
|
||||
and memory, then runs the documented inspect/history commands without provider
|
||||
credentials; `micro flow runs` preserves durable workflow history inspection.
|
||||
4. **Inspect** — `micro inspect agent <name>` and `micro inspect flow <name>`
|
||||
remain available as the local run-history inspection step, with `micro flow
|
||||
runs` preserving durable workflow history inspection.
|
||||
5. **Deploy** — `micro deploy --dry-run <target>` remains available as the
|
||||
deployment-boundary checkpoint. The dry run resolves configured deploy targets
|
||||
and services and prints the remote build/copy/systemd/health plan without
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
package zerotoheroci
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
goagent "go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
func TestZeroToHeroReferenceDocs(t *testing.T) {
|
||||
@@ -24,10 +17,8 @@ func TestZeroToHeroReferenceDocs(t *testing.T) {
|
||||
"go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1",
|
||||
"go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1",
|
||||
"go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1",
|
||||
"go test ./examples/first-agent -run TestRunFirstAgent -count=1",
|
||||
"go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1",
|
||||
"./internal/harness/zero-to-hero-ci/run.sh",
|
||||
"micro zero-to-hero",
|
||||
"go run ./internal/harness/agent-flow",
|
||||
"make provider-conformance-mock",
|
||||
"internal/harness/plan-delegate",
|
||||
@@ -83,111 +74,6 @@ func TestGuidesNavigationLeadsWithDoing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestYourFirstAgentTutorialSmoke(t *testing.T) {
|
||||
root := filepath.Clean(filepath.Join("..", "..", ".."))
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve repository root: %v", err)
|
||||
}
|
||||
guide := readFile(t, filepath.Join(root, "internal", "website", "docs", "guides", "your-first-agent.md"))
|
||||
|
||||
for _, want := range []string{
|
||||
"go test ./internal/harness/zero-to-hero-ci -run TestYourFirstAgentTutorialSmoke -count=1",
|
||||
"micro agent preflight",
|
||||
"mkdir first-agent",
|
||||
"go mod init example.com/first-agent",
|
||||
"go get go-micro.dev/v6@v6",
|
||||
"micro run",
|
||||
"micro call task TaskService.Create",
|
||||
"micro call task TaskService.List",
|
||||
"micro chat assistant",
|
||||
"micro inspect agent assistant",
|
||||
} {
|
||||
if !strings.Contains(guide, want) {
|
||||
t.Fatalf("Your First Agent guide missing copy/paste boundary %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
mainGo := extractFirstAgentMain(t, guide)
|
||||
workspace := t.TempDir()
|
||||
writeFile(t, filepath.Join(workspace, "go.mod"), "module example.com/first-agent\n\ngo 1.24\n\nrequire go-micro.dev/v6 v6.0.0\n\nreplace go-micro.dev/v6 => "+absRoot+"\n")
|
||||
writeFile(t, filepath.Join(workspace, "main.go"), mainGo)
|
||||
|
||||
runInWorkspace(t, workspace, "go", "mod", "tidy")
|
||||
runInWorkspace(t, workspace, "go", "test", "./...")
|
||||
}
|
||||
|
||||
func extractFirstAgentMain(t *testing.T, guide string) string {
|
||||
t.Helper()
|
||||
start := strings.Index(guide, "Add `main.go`:")
|
||||
if start == -1 {
|
||||
t.Fatal("Your First Agent guide is missing the main.go section")
|
||||
}
|
||||
rest := guide[start:]
|
||||
open := strings.Index(rest, "```go")
|
||||
if open == -1 {
|
||||
t.Fatal("Your First Agent guide is missing a Go code fence for main.go")
|
||||
}
|
||||
rest = rest[open+len("```go"):]
|
||||
close := strings.Index(rest, "```")
|
||||
if close == -1 {
|
||||
t.Fatal("Your First Agent guide main.go code fence is not closed")
|
||||
}
|
||||
return strings.TrimSpace(rest[:close]) + "\n"
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, name, contents string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(name, []byte(contents), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func runInWorkspace(t *testing.T, workspace, name string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Dir = workspace
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("Your First Agent tutorial command %q does not pass from a clean workspace: %v\n%s", strings.Join(append([]string{name}, args...), " "), err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchitectureDocsAlignWithAgentHarnessLifecycle(t *testing.T) {
|
||||
root := filepath.Clean(filepath.Join("..", "..", ".."))
|
||||
doc := readFile(t, filepath.Join(root, "internal", "website", "docs", "architecture.md"))
|
||||
|
||||
for _, want := range []string{
|
||||
"services → agents → workflows lifecycle",
|
||||
"## Service substrate",
|
||||
"## Agent harness",
|
||||
"## Workflows",
|
||||
"## Interop gateways",
|
||||
"`model` / `ai.Model`",
|
||||
"`store` / memory",
|
||||
"`ai.Tools`",
|
||||
"`agent`",
|
||||
"`flow`",
|
||||
"`micro mcp`",
|
||||
"`micro a2a`",
|
||||
"[AI Integration](ai-integration.html)",
|
||||
"[Your First Agent](guides/your-first-agent.html)",
|
||||
"[0→hero Reference](guides/zero-to-hero.html)",
|
||||
} {
|
||||
if !strings.Contains(doc, want) {
|
||||
t.Fatalf("architecture doc missing lifecycle marker %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
assertOrderedMarkers(t, "architecture lifecycle", doc, []string{
|
||||
"## Service substrate",
|
||||
"## Agent harness",
|
||||
"## Workflows",
|
||||
"## Interop gateways",
|
||||
"## Developer path",
|
||||
})
|
||||
}
|
||||
|
||||
func TestFirstAgentWayfindingDocs(t *testing.T) {
|
||||
root := filepath.Clean(filepath.Join("..", "..", ".."))
|
||||
checks := []struct {
|
||||
@@ -201,53 +87,17 @@ func TestFirstAgentWayfindingDocs(t *testing.T) {
|
||||
file: filepath.Join(root, "README.md"),
|
||||
heading: "### First agent on-ramp",
|
||||
links: []string{
|
||||
"internal/website/docs/guides/install-troubleshooting.md",
|
||||
"micro agent demo",
|
||||
"micro zero-to-hero",
|
||||
"internal/website/docs/guides/no-secret-first-agent.md",
|
||||
"internal/website/docs/guides/your-first-agent.md",
|
||||
"internal/website/docs/guides/debugging-agents.md",
|
||||
"internal/website/docs/guides/zero-to-hero.md",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "README examples list",
|
||||
file: filepath.Join(root, "README.md"),
|
||||
heading: "## Examples",
|
||||
links: []string{
|
||||
"examples/README.md",
|
||||
"examples/first-agent/",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "repository examples index",
|
||||
file: filepath.Join(root, "examples", "README.md"),
|
||||
heading: "## Recommended first-agent path",
|
||||
links: []string{
|
||||
"./first-agent/",
|
||||
"./support/",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "website examples index",
|
||||
file: filepath.Join(root, "internal", "website", "docs", "examples", "index.md"),
|
||||
heading: "## Start here",
|
||||
links: []string{
|
||||
"https://github.com/micro/go-micro/tree/master/examples/first-agent",
|
||||
"../guides/no-secret-first-agent.html",
|
||||
"../guides/your-first-agent.html",
|
||||
"../guides/debugging-agents.html",
|
||||
"../guides/zero-to-hero.html",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "website getting-started on-ramp",
|
||||
file: filepath.Join(root, "internal", "website", "docs", "getting-started.md"),
|
||||
heading: "### First-agent on-ramp",
|
||||
links: []string{
|
||||
"guides/install-troubleshooting.html",
|
||||
"micro agent demo",
|
||||
"micro zero-to-hero",
|
||||
"guides/no-secret-first-agent.html",
|
||||
"guides/your-first-agent.html",
|
||||
"guides/debugging-agents.html",
|
||||
@@ -265,7 +115,6 @@ func TestFirstAgentWayfindingDocs(t *testing.T) {
|
||||
if idx == -1 {
|
||||
t.Fatalf("%s missing first-agent wayfinding link %q; keep the no-secret → first-agent → debugging → 0→hero path discoverable", check.name, link)
|
||||
}
|
||||
assertWayfindingTargetExists(t, root, check.file, link)
|
||||
if idx < last {
|
||||
t.Fatalf("%s link %q appeared out of order; expected no-secret → first-agent → debugging → 0→hero", check.name, link)
|
||||
}
|
||||
@@ -275,265 +124,11 @@ func TestFirstAgentWayfindingDocs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstAgentWayfindingLinkTargetsResolve(t *testing.T) {
|
||||
root := filepath.Clean(filepath.Join("..", "..", ".."))
|
||||
checks := []struct {
|
||||
name string
|
||||
file string
|
||||
heading string
|
||||
}{
|
||||
{
|
||||
name: "README first-agent on-ramp",
|
||||
file: filepath.Join(root, "README.md"),
|
||||
heading: "### First agent on-ramp",
|
||||
},
|
||||
{
|
||||
name: "README examples list",
|
||||
file: filepath.Join(root, "README.md"),
|
||||
heading: "## Examples",
|
||||
},
|
||||
{
|
||||
name: "repository examples index",
|
||||
file: filepath.Join(root, "examples", "README.md"),
|
||||
heading: "## Recommended first-agent path",
|
||||
},
|
||||
{
|
||||
name: "website examples index",
|
||||
file: filepath.Join(root, "internal", "website", "docs", "examples", "index.md"),
|
||||
heading: "## Start here",
|
||||
},
|
||||
{
|
||||
name: "website getting-started on-ramp",
|
||||
file: filepath.Join(root, "internal", "website", "docs", "getting-started.md"),
|
||||
heading: "### First-agent on-ramp",
|
||||
},
|
||||
}
|
||||
|
||||
for _, check := range checks {
|
||||
t.Run(check.name, func(t *testing.T) {
|
||||
section := firstMarkdownSection(t, readFile(t, check.file), check.heading)
|
||||
links := markdownLinks(section)
|
||||
if len(links) == 0 {
|
||||
t.Fatalf("%s has no Markdown links in %q", check.name, check.heading)
|
||||
}
|
||||
for _, link := range links {
|
||||
assertWayfindingTargetExists(t, root, check.file, link)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstAgentLifecycleCommandOrderIsDocumented(t *testing.T) {
|
||||
root := filepath.Clean(filepath.Join("..", "..", ".."))
|
||||
checks := []struct {
|
||||
name string
|
||||
file string
|
||||
heading string
|
||||
markers []string
|
||||
}{
|
||||
{
|
||||
name: "0→hero guide lifecycle",
|
||||
file: filepath.Join(root, "internal", "website", "docs", "guides", "zero-to-hero.md"),
|
||||
heading: "## What the contract covers",
|
||||
markers: []string{"micro new", "micro run", "micro chat", "micro inspect agent", "micro deploy --dry-run"},
|
||||
},
|
||||
{
|
||||
name: "CLI docs lifecycle",
|
||||
file: filepath.Join(root, "cmd", "micro", "cli", "cli.go"),
|
||||
heading: "const docsWayfinding",
|
||||
markers: []string{"micro agent demo", "micro run", "micro chat", "micro inspect agent", "deploy dry-run"},
|
||||
},
|
||||
{
|
||||
name: "scaffold next steps",
|
||||
file: filepath.Join(root, "cmd", "micro", "cli", "new", "new.go"),
|
||||
heading: "func printNextSteps",
|
||||
markers: []string{"go run .", "micro chat", "micro inspect agent", "micro agent demo", "micro docs"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, check := range checks {
|
||||
t.Run(check.name, func(t *testing.T) {
|
||||
doc := readFile(t, check.file)
|
||||
if check.heading != "" {
|
||||
start := strings.Index(doc, check.heading)
|
||||
if start == -1 {
|
||||
t.Fatalf("%s missing %q boundary", check.name, check.heading)
|
||||
}
|
||||
doc = doc[start:]
|
||||
}
|
||||
assertOrderedMarkers(t, check.name, doc, check.markers)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExamplesIndexesPreserveLifecycleMap(t *testing.T) {
|
||||
root := filepath.Clean(filepath.Join("..", "..", ".."))
|
||||
checks := []struct {
|
||||
name string
|
||||
file string
|
||||
heading string
|
||||
want []string
|
||||
ordered []string
|
||||
}{
|
||||
{
|
||||
name: "repository examples lifecycle map",
|
||||
file: filepath.Join(root, "examples", "README.md"),
|
||||
heading: "## Recommended first-agent path",
|
||||
want: []string{
|
||||
"hello-world",
|
||||
"0→1",
|
||||
"first-agent",
|
||||
"support",
|
||||
"services",
|
||||
"agents",
|
||||
"workflows",
|
||||
"Debugging and observability",
|
||||
},
|
||||
ordered: []string{"1. First service", "2. First agent", "3. First workflow"},
|
||||
},
|
||||
{
|
||||
name: "website examples lifecycle map",
|
||||
file: filepath.Join(root, "internal", "website", "docs", "examples", "index.md"),
|
||||
heading: "## Start here",
|
||||
want: []string{
|
||||
"examples/hello-world",
|
||||
"0→1",
|
||||
"examples/first-agent",
|
||||
"examples/support",
|
||||
"services",
|
||||
"agents",
|
||||
"workflows",
|
||||
"debugging-agents.html",
|
||||
},
|
||||
ordered: []string{"0→1 service", "Provider-free first agent", "0→hero lifecycle"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, check := range checks {
|
||||
t.Run(check.name, func(t *testing.T) {
|
||||
section := firstMarkdownSection(t, readFile(t, check.file), check.heading)
|
||||
for _, want := range check.want {
|
||||
if !strings.Contains(section, want) {
|
||||
t.Fatalf("%s missing lifecycle map marker %q", check.name, want)
|
||||
}
|
||||
}
|
||||
|
||||
last := -1
|
||||
for _, marker := range check.ordered {
|
||||
idx := strings.Index(section, marker)
|
||||
if idx == -1 {
|
||||
t.Fatalf("%s missing ordered example marker %q", check.name, marker)
|
||||
}
|
||||
if idx < last {
|
||||
t.Fatalf("%s marker %q appeared out of order; keep examples flowing hello-world/0→1 → first-agent → support/0→hero", check.name, marker)
|
||||
}
|
||||
last = idx
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGettingStartedDocsLeadWithNoSecretFirstRun(t *testing.T) {
|
||||
root := filepath.Clean(filepath.Join("..", "..", ".."))
|
||||
checks := []struct {
|
||||
name string
|
||||
file string
|
||||
section string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "README quick start",
|
||||
file: filepath.Join(root, "README.md"),
|
||||
section: "## Quick Start",
|
||||
want: []string{
|
||||
"install troubleshooting guide",
|
||||
"### Fastest start — no API key",
|
||||
"micro new helloworld",
|
||||
"micro run",
|
||||
"curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call",
|
||||
"### First agent on-ramp",
|
||||
"micro agent demo",
|
||||
"### Generate from a prompt — with an LLM key",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "CLI README",
|
||||
file: filepath.Join(root, "cmd", "micro", "README.md"),
|
||||
section: "## Create a service",
|
||||
want: []string{
|
||||
"## Create a service",
|
||||
"micro new helloworld",
|
||||
"## Run the service",
|
||||
"micro run",
|
||||
"micro agent demo",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "website getting started",
|
||||
file: filepath.Join(root, "internal", "website", "docs", "getting-started.md"),
|
||||
section: "Install troubleshooting",
|
||||
want: []string{
|
||||
"Install troubleshooting",
|
||||
"## Quick Start: Scaffold, Run, Call",
|
||||
"micro new helloworld",
|
||||
"micro run",
|
||||
"curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call",
|
||||
"### First-agent on-ramp",
|
||||
"micro agent demo",
|
||||
"## Generate from a Prompt — with an LLM key",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "website quickstart",
|
||||
file: filepath.Join(root, "internal", "website", "docs", "quickstart.md"),
|
||||
section: "## Create Your First Service",
|
||||
want: []string{
|
||||
"micro new helloworld",
|
||||
"micro run",
|
||||
"curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call",
|
||||
"## Next Steps",
|
||||
"micro agent demo",
|
||||
"micro zero-to-hero",
|
||||
"guides/no-secret-first-agent.html",
|
||||
"guides/debugging-agents.html",
|
||||
"guides/zero-to-hero.html",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, check := range checks {
|
||||
t.Run(check.name, func(t *testing.T) {
|
||||
doc := readFile(t, check.file)
|
||||
if check.section != "" {
|
||||
start := strings.Index(doc, check.section)
|
||||
if start == -1 {
|
||||
t.Fatalf("%s missing %q section", check.name, check.section)
|
||||
}
|
||||
doc = doc[start:]
|
||||
}
|
||||
last := -1
|
||||
for _, want := range check.want {
|
||||
idx := strings.Index(doc, want)
|
||||
if idx == -1 {
|
||||
t.Fatalf("%s missing no-secret first-run marker %q", check.name, want)
|
||||
}
|
||||
if idx < last {
|
||||
t.Fatalf("%s marker %q appeared out of order; keep install/scaffold/run/call before provider-backed generation", check.name, want)
|
||||
}
|
||||
last = idx
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoSecretFirstAgentTranscript(t *testing.T) {
|
||||
root := filepath.Clean(filepath.Join("..", "..", ".."))
|
||||
guide := readFile(t, filepath.Join(root, "internal", "website", "docs", "guides", "no-secret-first-agent.md"))
|
||||
|
||||
for _, want := range []string{
|
||||
"micro agent demo",
|
||||
"go run ./examples/first-agent",
|
||||
"go test ./examples/first-agent -run TestRunFirstAgent -count=1",
|
||||
"go run ./examples/support",
|
||||
"go test ./examples/support -run TestRunSupportMockSmoke -count=1",
|
||||
"make harness",
|
||||
@@ -549,20 +144,6 @@ func TestNoSecretFirstAgentTranscript(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
debugCheckpoint := firstMarkdownSection(t, guide, "## Debug transcript checkpoint")
|
||||
for _, want := range []string{
|
||||
`micro chat assistant --prompt "Triage ticket-1 for Alice"`,
|
||||
"micro inspect agent assistant --limit 1",
|
||||
"micro agent history assistant",
|
||||
"status, event count, last event",
|
||||
"Debugging your agent",
|
||||
"debugging-agents.html",
|
||||
} {
|
||||
if !strings.Contains(debugCheckpoint, want) {
|
||||
t.Fatalf("no-secret debug transcript checkpoint missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
readme := readFile(t, filepath.Join(root, "README.md"))
|
||||
if !strings.Contains(readme, "internal/website/docs/guides/no-secret-first-agent.md") {
|
||||
t.Fatal("README does not point to the no-secret first-agent transcript")
|
||||
@@ -574,127 +155,6 @@ func TestNoSecretFirstAgentTranscript(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoSecretFirstAgentDebuggingSmoke(t *testing.T) {
|
||||
root := filepath.Clean(filepath.Join("..", "..", ".."))
|
||||
home := t.TempDir()
|
||||
storeDir := filepath.Join(home, "micro", "store")
|
||||
st := store.NewFileStore(store.DirOption(storeDir))
|
||||
|
||||
seedNoSecretAgentDebuggingState(t, st)
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("close seeded store: %v", err)
|
||||
}
|
||||
|
||||
micro := buildMicroBinary(t, root)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "demo advertises provider-free debug path",
|
||||
args: []string{"agent", "demo"},
|
||||
want: []string{"No-secret first-agent demo", "provider-free", "run history", "micro inspect agent <name>"},
|
||||
},
|
||||
{
|
||||
name: "inspect shows seeded run history",
|
||||
args: []string{"inspect", "agent", "assistant", "--limit", "1"},
|
||||
want: []string{`Agent "assistant" runs`, "run-debug-smoke", "status=done", "events=3", "last=done", "trace=trace-debug-"},
|
||||
},
|
||||
{
|
||||
name: "inspect filters documented statuses",
|
||||
args: []string{"inspect", "agent", "--status", "done", "--json", "assistant"},
|
||||
want: []string{"run-debug-smoke", `"status": "done"`, `"trace_id": "trace-debug-smoke"`},
|
||||
},
|
||||
{
|
||||
name: "agent history shows memory and run index",
|
||||
args: []string{"agent", "history", "assistant"},
|
||||
want: []string{"user:", "Triage ticket-1", "assistant:", "ticket-1 is ready", "Runs:", "run-debug-smoke", "status=done"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out := runMicroCLIWithHome(t, micro, home, tc.args...)
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("micro %s output missing %q:\n%s", strings.Join(tc.args, " "), want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func seedNoSecretAgentDebuggingState(t *testing.T, st store.Store) {
|
||||
t.Helper()
|
||||
scoped := store.Scope(st, "agent", "assistant")
|
||||
runID := "run-debug-smoke"
|
||||
events := []goagent.RunEvent{
|
||||
{Time: time.Unix(1700000000, 0), RunID: runID, Agent: "assistant", TraceID: "trace-debug-smoke", Kind: "run", Name: "ask"},
|
||||
{Time: time.Unix(1700000001, 0), RunID: runID, Agent: "assistant", TraceID: "trace-debug-smoke", Kind: "model", Provider: "mock", Model: "first-agent-mock"},
|
||||
{Time: time.Unix(1700000002, 0), RunID: runID, Agent: "assistant", TraceID: "trace-debug-smoke", Kind: "done", Name: "answer"},
|
||||
}
|
||||
for _, event := range events {
|
||||
b, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := "runs/" + event.RunID + "/" + event.Time.Format("20060102150405.000000000") + "-" + event.Kind
|
||||
if err := scoped.Write(&store.Record{Key: key, Value: b}); err != nil {
|
||||
t.Fatalf("seed run event: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
mem := goagent.NewMemory(scoped, "history", 10)
|
||||
mem.Add("user", "Triage ticket-1 for Alice")
|
||||
mem.Add("assistant", "ticket-1 is ready for Alice without provider secrets")
|
||||
}
|
||||
|
||||
func buildMicroBinary(t *testing.T, root string) string {
|
||||
t.Helper()
|
||||
bin := filepath.Join(t.TempDir(), "micro")
|
||||
cmd := exec.Command("go", "build", "-o", bin, "./cmd/micro")
|
||||
cmd.Dir = root
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("build micro CLI failed: %v\n%s", err, out)
|
||||
}
|
||||
return bin
|
||||
}
|
||||
|
||||
func runMicroCLIWithHome(t *testing.T, micro, home string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command(micro, args...)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"HOME="+home,
|
||||
"MICRO_AI_API_KEY=",
|
||||
"OPENAI_API_KEY=",
|
||||
"ANTHROPIC_API_KEY=",
|
||||
"GEMINI_API_KEY=",
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("micro %s failed: %v\n%s", strings.Join(args, " "), err, out)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func TestFirstAgentWayfindingTargetsExist(t *testing.T) {
|
||||
root := filepath.Clean(filepath.Join("..", "..", ".."))
|
||||
for _, target := range []string{
|
||||
"examples/README.md",
|
||||
"examples/first-agent/README.md",
|
||||
"internal/website/docs/examples/index.md",
|
||||
"internal/website/docs/guides/no-secret-first-agent.md",
|
||||
"internal/website/docs/guides/your-first-agent.md",
|
||||
"internal/website/docs/guides/debugging-agents.md",
|
||||
"internal/website/docs/guides/zero-to-hero.md",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(target))); err != nil {
|
||||
t.Fatalf("first-agent wayfinding target %s disappeared: %v", target, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func firstMarkdownSection(t *testing.T, doc, heading string) string {
|
||||
t.Helper()
|
||||
start := strings.Index(doc, heading)
|
||||
@@ -716,67 +176,3 @@ func readFile(t *testing.T, name string) string {
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
var markdownLinkRE = regexp.MustCompile(`\[[^\]]+\]\(([^)#?]+)(?:[#?][^)]*)?\)`)
|
||||
|
||||
func markdownLinks(section string) []string {
|
||||
matches := markdownLinkRE.FindAllStringSubmatch(section, -1)
|
||||
links := make([]string, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
if len(match) > 1 {
|
||||
links = append(links, match[1])
|
||||
}
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
func assertWayfindingTargetExists(t *testing.T, root, sourceFile, link string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(link, "/") && !strings.Contains(link, ".") {
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(link, "http://") || strings.HasPrefix(link, "https://") {
|
||||
switch {
|
||||
case strings.HasPrefix(link, "https://go-micro.dev/docs/"):
|
||||
link = strings.TrimPrefix(link, "https://go-micro.dev/docs/")
|
||||
link = filepath.ToSlash(filepath.Join("internal", "website", "docs", strings.TrimSuffix(link, ".html")+".md"))
|
||||
case strings.HasPrefix(link, "https://github.com/micro/go-micro/tree/master/"):
|
||||
link = strings.TrimPrefix(link, "https://github.com/micro/go-micro/tree/master/")
|
||||
default:
|
||||
return
|
||||
}
|
||||
} else if strings.HasSuffix(link, ".html") {
|
||||
sourceDir := filepath.Dir(sourceFile)
|
||||
websiteDocs := filepath.Join(root, "internal", "website", "docs")
|
||||
resolved := filepath.Clean(filepath.Join(sourceDir, filepath.FromSlash(link)))
|
||||
if rel, err := filepath.Rel(websiteDocs, resolved); err == nil && !strings.HasPrefix(rel, "..") {
|
||||
link = filepath.ToSlash(filepath.Join("internal", "website", "docs", strings.TrimSuffix(rel, ".html")+".md"))
|
||||
}
|
||||
} else if strings.HasPrefix(link, ".") {
|
||||
target := filepath.Clean(filepath.Join(filepath.Dir(sourceFile), filepath.FromSlash(link)))
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
t.Fatalf("first-agent wayfinding link %q in %s resolves to missing target %s: %v", link, sourceFile, target, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
target := filepath.Join(root, filepath.FromSlash(link))
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
t.Fatalf("first-agent wayfinding link %q in %s resolves to missing target %s: %v", link, sourceFile, target, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertOrderedMarkers(t *testing.T, name, doc string, markers []string) {
|
||||
t.Helper()
|
||||
last := -1
|
||||
for _, marker := range markers {
|
||||
idx := strings.Index(doc, marker)
|
||||
if idx == -1 {
|
||||
t.Fatalf("%s missing lifecycle command marker %q", name, marker)
|
||||
}
|
||||
if idx < last {
|
||||
t.Fatalf("%s marker %q appeared out of order; keep scaffold → run → chat → inspect → deploy discoverable", name, marker)
|
||||
}
|
||||
last = idx
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,10 @@ cd "$ROOT"
|
||||
# without secrets or long-running daemons.
|
||||
go test ./cmd/micro -run 'TestFirstAgentWalkthroughCLIBoundaries|TestZeroToHeroCLIBoundaries' -count=1
|
||||
go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1
|
||||
go test ./internal/harness/zero-to-hero-ci -run 'TestNoSecretFirstAgentTranscript|TestNoSecretFirstAgentDebuggingSmoke|TestZeroToHeroReferenceDocs|TestYourFirstAgentTutorialSmoke' -count=1
|
||||
|
||||
# Deterministic no-secret reference scenarios. These use the real Go Micro
|
||||
# runtime and mock only the LLM provider. The support example is the maintained
|
||||
# runnable 0→hero app; keep it in this CI path so its documented run/chat/inspect
|
||||
# journey cannot drift from the framework.
|
||||
go test ./examples/first-agent -run TestRunFirstAgent -count=1
|
||||
go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1
|
||||
go test ./internal/harness/universe ./internal/harness/plan-delegate -run 'Test.*Harness|TestPlanDelegateEndToEnd|TestPlanDelegateFlowHandoff' -count=1
|
||||
|
||||
@@ -3,8 +3,6 @@ core:
|
||||
url: /docs/
|
||||
- title: Getting Started
|
||||
url: /docs/getting-started.html
|
||||
- title: Install Troubleshooting
|
||||
url: /docs/guides/install-troubleshooting.html
|
||||
- title: AI Integration
|
||||
url: /docs/ai-integration.html
|
||||
- title: No-secret First Agent
|
||||
@@ -42,8 +40,6 @@ examples:
|
||||
guides:
|
||||
- title: Debugging your agent
|
||||
url: /docs/guides/debugging-agents.html
|
||||
- title: micro loop quickstart
|
||||
url: /docs/guides/micro-loop.html
|
||||
- title: Plan & Delegate
|
||||
url: /docs/guides/plan-delegate.html
|
||||
- title: Agent Guardrails
|
||||
@@ -84,11 +80,9 @@ project:
|
||||
- title: Server (optional)
|
||||
url: /docs/server.html
|
||||
search_order:
|
||||
- /docs/guides/install-troubleshooting.html
|
||||
- /docs/guides/your-first-agent.html
|
||||
- /docs/guides/zero-to-hero.html
|
||||
- /docs/guides/debugging-agents.html
|
||||
- /docs/guides/micro-loop.html
|
||||
- /docs/getting-started.html
|
||||
- /docs/mcp.html
|
||||
- /docs/architecture.html
|
||||
|
||||
@@ -5,29 +5,27 @@ title: AI Integration
|
||||
|
||||
# AI Integration
|
||||
|
||||
Go Micro is an agent harness and service framework for Go. Every service you build can become an AI-callable tool, every agent runs as a service with model/memory/guardrails around it, and flows orchestrate the deterministic parts. This page explains how the services → agents → workflows lifecycle fits together.
|
||||
Go Micro is an AI-native microservices framework. Every service you build is automatically accessible to AI agents, and every service can call AI models. This page explains how the pieces fit together.
|
||||
|
||||
<img src="/images/generated/mcp-agent.jpg" alt="AI integration architecture" style="width: 100%; border-radius: 8px; margin: 1rem 0 1.5rem;" />
|
||||
|
||||
## The Stack
|
||||
|
||||
```
|
||||
Services → write Go handlers, register with the framework
|
||||
Your Services → write Go handlers, register with the framework
|
||||
↓
|
||||
Registry → automatic discovery for services, agents, and flows
|
||||
Registry → automatic service discovery (mDNS, Consul, etcd)
|
||||
↓
|
||||
Gateways → micro api (HTTP→RPC), micro mcp (tools), micro a2a (agents)
|
||||
Gateways → micro api (HTTP→RPC) / micro mcp (MCP tools)
|
||||
↓
|
||||
ai.Tools → discovers services + executes RPCs programmatically
|
||||
↓
|
||||
ai.Model → calls LLMs (Anthropic, OpenAI, Gemini, Atlas Cloud, ...)
|
||||
↓
|
||||
Agents → service-backed model loop with memory, guardrails, plan/delegate
|
||||
↓
|
||||
Flows → durable deterministic steps that can dispatch to agents
|
||||
agent / flow / micro chat → agent-managed, event-driven, or interactive orchestration
|
||||
```
|
||||
|
||||
Every layer is optional. You can use Go Micro as a service framework without AI. You can use the `ai` package without MCP. But when you stack them, you get one runtime where services become tools, agents are reachable services, and workflows coordinate the predictable parts.
|
||||
Every layer is optional. You can use go-micro without AI. You can use the `ai` package without MCP. But when you stack them, you get services that AI agents can discover and orchestrate automatically.
|
||||
|
||||
## Layer by Layer
|
||||
|
||||
|
||||
@@ -2,124 +2,75 @@
|
||||
layout: default
|
||||
---
|
||||
|
||||
# Architecture
|
||||
## Architecture
|
||||
|
||||
<img src="/images/generated/architecture.jpg" alt="Go Micro architecture" style="width: 100%; border-radius: 8px; margin: 1rem 0 1.5rem;" />
|
||||
|
||||
Go Micro is one runtime for the services → agents → workflows lifecycle. The same
|
||||
registry, client/server RPC, store, broker, and gateway primitives that run a
|
||||
service also give an agent discoverable tools, durable state, interop, and a
|
||||
place to hand off deterministic work.
|
||||
An overview of the Go Micro architecture.
|
||||
|
||||
## Lifecycle map
|
||||
## Overview
|
||||
|
||||
```text
|
||||
Services → Agents → Workflows
|
||||
handlers model loop durable orchestration
|
||||
registry memory triggers and ordered steps
|
||||
RPC tools guardrails agent dispatch
|
||||
```
|
||||
Go Micro abstracts away the details of distributed systems. Here are the main features.
|
||||
|
||||
The layers are progressive: start with a service, expose its endpoints as tools,
|
||||
wrap those tools with an agent, then move the known paths into flows so the model
|
||||
only handles the uncertain parts.
|
||||
- **Authentication** - Auth is built in as a first class citizen. Authentication and authorization enable secure
|
||||
zero trust networking by providing every service an identity and certificates. This additionally includes rule
|
||||
based access control.
|
||||
|
||||
## Service substrate
|
||||
- **Dynamic Config** - Load and hot reload dynamic config from anywhere. The config interface provides a way to load application
|
||||
level config from any source such as env vars, file, etcd. You can merge the sources and even define fallbacks.
|
||||
|
||||
Go Micro's service framework supplies the distributed-systems base every agent
|
||||
needs:
|
||||
- **Data Storage** - A simple data store interface to read, write and delete records. It includes support for many storage backends
|
||||
in the plugins repo. State and persistence becomes a core requirement beyond prototyping and Micro looks to build that into the framework.
|
||||
|
||||
- **Registry** — services, agents, and flows register under names so clients,
|
||||
gateways, and other agents can discover them without hard-coded addresses. The
|
||||
default is mDNS for local development, with pluggable backends for production.
|
||||
- **RPC client/server** — endpoints are normal Go handlers reached through the
|
||||
client, load balanced through discovery, encoded through codecs, and optionally
|
||||
streamed.
|
||||
- **Broker** — asynchronous events connect services and trigger flows without
|
||||
coupling producers to consumers.
|
||||
- **Config and auth** — dynamic configuration plus identity and authorization keep
|
||||
local and production runtimes using the same shape.
|
||||
- **Pluggable interfaces** — registry, broker, store, transport, codecs, auth, and
|
||||
config are Go interfaces, so the runtime can stay stable while deployments swap
|
||||
infrastructure.
|
||||
- **Service Discovery** - Automatic service registration and name resolution. Service discovery is at the core of micro service
|
||||
development. When service A needs to speak to service B it needs the location of that service. The default discovery mechanism is
|
||||
multicast DNS (mdns), a zeroconf system.
|
||||
|
||||
That substrate is intentionally not separate from the agent stack. A service
|
||||
endpoint is the smallest useful unit of work, and the registry is the source of
|
||||
truth for which tools and agents exist.
|
||||
- **Load Balancing** - Client side load balancing built on service discovery. Once we have the addresses of any number of instances
|
||||
of a service we now need a way to decide which node to route to. We use random hashed load balancing to provide even distribution
|
||||
across the services and retry a different node if there's a problem.
|
||||
|
||||
## Agent harness
|
||||
- **Message Encoding** - Dynamic message encoding based on content-type. The client and server will use codecs along with content-type
|
||||
to seamlessly encode and decode Go types for you. Any variety of messages could be encoded and sent from different clients. The client
|
||||
and server handle this by default. This includes protobuf and json by default.
|
||||
|
||||
Agents compose the service substrate with the AI-specific packages:
|
||||
- **RPC Client/Server** - RPC based request/response with support for bidirectional streaming. We provide an abstraction for synchronous
|
||||
communication. A request made to a service will be automatically resolved, load balanced, dialled and streamed.
|
||||
|
||||
- **`model` / `ai.Model`** — a pluggable model interface normalizes provider calls
|
||||
while letting applications pick Anthropic, OpenAI, Gemini, Atlas Cloud, Groq,
|
||||
Mistral, Together AI, or a mock model for no-secret tests.
|
||||
- **`store` / memory** — agent history, plans, run state, and compacted memory live
|
||||
in durable storage rather than in an in-process chat loop.
|
||||
- **`ai.Tools`** — discovers registered service endpoints and executes them through
|
||||
the Go Micro client, so tools are generated from running services instead of a
|
||||
parallel tool registry.
|
||||
- **`agent`** — runs the tool-calling loop with guardrails, planning, delegation,
|
||||
service-backed memory, and an `Agent.Chat` RPC endpoint. An agent is therefore a
|
||||
service other clients and agents can call.
|
||||
- **Async Messaging** - PubSub is built in as a first class citizen for asynchronous communication and event driven architectures.
|
||||
Event notifications are a core pattern in micro service development. The default messaging system is a HTTP event message broker.
|
||||
|
||||
The result is a harness, not just a prompt loop: model calls are bounded by tool
|
||||
scope, state is recoverable, and the same CLI and gateways that reach services can
|
||||
reach agents.
|
||||
- **Pluggable Interfaces** - Go Micro makes use of Go interfaces for each distributed system abstraction. Because of this these interfaces
|
||||
are pluggable and allows Go Micro to be runtime agnostic. You can plugin any underlying technology.
|
||||
|
||||
## Workflows
|
||||
## Design
|
||||
|
||||
Use `flow` when the path is known or must be repeatable. Flows subscribe to broker
|
||||
events, run ordered deterministic steps, and can dispatch to an agent at the point
|
||||
where judgment or language understanding is needed. This keeps long-running work
|
||||
observable and restartable while preserving agents for open-ended decisions.
|
||||
|
||||
A common shape is:
|
||||
|
||||
1. A service emits an event such as `ticket.created`.
|
||||
2. A flow validates and enriches the event with deterministic handlers.
|
||||
3. The flow dispatches to an agent for classification, drafting, or escalation.
|
||||
4. The agent calls registered service tools and returns to the flow for final
|
||||
durable steps.
|
||||
|
||||
## Interop gateways
|
||||
|
||||
Gateways project the same runtime to external callers:
|
||||
|
||||
- **`micro api`** exposes service RPC over HTTP.
|
||||
- **`micro mcp`** exposes registered service endpoints as Model Context Protocol
|
||||
tools for external agents.
|
||||
- **`micro a2a`** exposes registered Go Micro agents through the Agent2Agent
|
||||
protocol and lets Go Micro flows or agents dispatch to agents hosted elsewhere.
|
||||
|
||||
MCP is the services-as-tools boundary; A2A is the agents-as-agents boundary. Both
|
||||
come from registry metadata, so adding a service or agent updates the external
|
||||
surface without duplicate wiring.
|
||||
|
||||
## Developer path
|
||||
|
||||
If you are new, follow the architecture in the same order the runtime composes it:
|
||||
|
||||
1. [Install troubleshooting](guides/install-troubleshooting.html) — make sure the
|
||||
CLI, `PATH`, version, and no-secret smoke path are healthy.
|
||||
2. [`micro agent demo`](getting-started.html#first-agent-on-ramp) — print the
|
||||
provider-free first-agent command and next docs steps from the installed CLI.
|
||||
3. [Smallest first-agent example](https://github.com/micro/go-micro/tree/master/examples/first-agent)
|
||||
— run one service-backed agent with a mock model.
|
||||
4. [No-secret first-agent transcript](guides/no-secret-first-agent.html) — see the
|
||||
maintained support-agent path work without a provider key.
|
||||
5. [Your First Agent](guides/your-first-agent.html) — build and chat with a
|
||||
service-backed agent.
|
||||
6. [Debugging your agent](guides/debugging-agents.html) — inspect service
|
||||
registration, tools, memory, providers, and run history.
|
||||
7. [0→hero Reference](guides/zero-to-hero.html) — walk scaffold → run → chat →
|
||||
inspect → flow → deploy dry-run as the maintained lifecycle contract.
|
||||
We will share more on architecture soon
|
||||
|
||||
## Related
|
||||
|
||||
- [AI Integration](ai-integration.html) — layer-by-layer services → agents → workflows wiring
|
||||
- [Getting Started](getting-started.html) — first service and first-agent on-ramp
|
||||
- [Examples](examples/) — runnable examples mapped to the lifecycle
|
||||
- [ADR Index](architecture/index.md) — architecture decision records
|
||||
- [ADR Index](architecture/index.md)
|
||||
- [Configuration](config.html)
|
||||
- [Plugins](plugins.html)
|
||||
|
||||
## Example Usage
|
||||
|
||||
Here's a minimal Go Micro service demonstrating the architecture:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"go-micro.dev/v6"
|
||||
"log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
service := micro.NewService("example",
|
||||
)
|
||||
service.Init()
|
||||
if err := service.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -10,12 +10,9 @@ agents → workflows lifecycle.
|
||||
|
||||
## Start here
|
||||
|
||||
For the provider-free first-agent route, run [`examples/first-agent`](https://github.com/micro/go-micro/tree/master/examples/first-agent), then follow [No-secret First Agent](../guides/no-secret-first-agent.html), [Your First Agent](../guides/your-first-agent.html), [Debugging your agent](../guides/debugging-agents.html), and the [0→hero Reference](../guides/zero-to-hero.html).
|
||||
|
||||
| Goal | Runnable example | Why it is useful |
|
||||
| --- | --- | --- |
|
||||
| 0→1 service | [`examples/hello-world`](https://github.com/micro/go-micro/tree/master/examples/hello-world) | Smallest RPC service with a client call and health checks. |
|
||||
| Provider-free first agent | [`examples/first-agent`](https://github.com/micro/go-micro/tree/master/examples/first-agent) | Smallest service-backed agent with a deterministic mock model; no provider key required. |
|
||||
| First service-backed agent | [`examples/agent-demo`](https://github.com/micro/go-micro/tree/master/examples/agent-demo) | Multi-service project/task/team app with agent playground integration. |
|
||||
| 0→hero lifecycle | [`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support) | No-secret support-desk story: typed services, an agent, an event-driven flow, and a guardrail. |
|
||||
| Planning and delegation | [`examples/agent-plan-delegate`](https://github.com/micro/go-micro/tree/master/examples/agent-plan-delegate) | Two agents collaborate through `plan` and `delegate` over normal Go Micro RPC. |
|
||||
@@ -28,16 +25,10 @@ For the provider-free first-agent route, run [`examples/first-agent`](https://gi
|
||||
- [Getting Started](../getting-started.html) → run
|
||||
[`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support)
|
||||
to see the full lifecycle before generating your own service.
|
||||
- [No-secret First Agent](../guides/no-secret-first-agent.html) → run
|
||||
[`examples/first-agent`](https://github.com/micro/go-micro/tree/master/examples/first-agent)
|
||||
first for the smallest provider-free agent transcript.
|
||||
- [Your First Agent](../guides/your-first-agent.html) → run
|
||||
[`examples/agent-demo`](https://github.com/micro/go-micro/tree/master/examples/agent-demo)
|
||||
or [`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support)
|
||||
when you want a complete service-backed agent to inspect.
|
||||
- [Debugging your agent](../guides/debugging-agents.html) → keep
|
||||
[`examples/first-agent`](https://github.com/micro/go-micro/tree/master/examples/first-agent)
|
||||
nearby as the smallest mock-model reproduction before inspecting richer runs.
|
||||
- [0→hero Reference](../guides/zero-to-hero.html) → run
|
||||
[`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support)
|
||||
for the human-readable scenario, then `make harness` for the full CI contract.
|
||||
|
||||
@@ -17,7 +17,15 @@ Go Micro has three core abstractions:
|
||||
## Prerequisites
|
||||
|
||||
- **Go 1.24+** for development. The `curl` install below gives you the `micro` binary without Go, but `micro run` compiles your services, so you'll want Go installed to build them.
|
||||
- **No LLM provider key is required** for the first run below. Add an Anthropic, OpenAI, Gemini, or other provider key only when you reach the provider-backed generation and chat steps.
|
||||
- An **LLM provider key** (Anthropic, OpenAI, Gemini, …) *only* for the AI features — `micro run --prompt`, `micro chat`, and agents. Plain services need no key. Set it before running, e.g. `export ANTHROPIC_API_KEY=sk-ant-...`.
|
||||
|
||||
Before your first provider-backed agent run, check the local path with:
|
||||
|
||||
```bash
|
||||
micro agent preflight
|
||||
```
|
||||
|
||||
The preflight is read-only: it verifies Go 1.24+, the `micro` binary, provider-key setup, and whether the default `micro run` gateway port is free, without calling an LLM provider. When a check fails it prints the exact fix plus the next guide to open, so the scaffold → run → chat path stays walkable.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -29,43 +37,65 @@ curl -fsSL https://go-micro.dev/install.sh | sh
|
||||
go install go-micro.dev/v6/cmd/micro@latest
|
||||
```
|
||||
|
||||
If install or shell setup fails, start with [Install troubleshooting](guides/install-troubleshooting.html) to verify the binary installer or `go install`, `PATH`, `micro --version`, and the no-secret smoke path.
|
||||
## Quick Start: Generate from a Prompt
|
||||
|
||||
## Quick Start: Scaffold, Run, Call
|
||||
|
||||
Start with the path that proves the runtime works before any provider setup: install the CLI, scaffold one service, run it locally, then call it through the gateway.
|
||||
Prefer to start from a runnable reference? Clone the repository and run the maintained support-desk lifecycle example first:
|
||||
|
||||
```bash
|
||||
micro new helloworld
|
||||
cd helloworld
|
||||
micro run
|
||||
git clone https://github.com/micro/go-micro.git
|
||||
cd go-micro
|
||||
go run ./examples/support
|
||||
```
|
||||
|
||||
In another terminal, call the generated service:
|
||||
That example is the no-secret 0→hero path: services expose ticket/customer/notification tools, an agent handles the work, and an event-driven flow triggers the agent. See [Learn by Example](examples/) when you want more runnable starting points.
|
||||
|
||||
Describe what you need. The AI designs services, writes handlers, compiles, and starts them:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call \
|
||||
-H 'Content-Type: application/json' -d '{"name":"World"}'
|
||||
micro run --prompt "task management system"
|
||||
```
|
||||
|
||||
That install → scaffold → run → call loop is the 0→1 contract. It requires Go and the `micro` binary, but no LLM key. Once this succeeds, you know the local runtime, hot reload, gateway, and service registration are working.
|
||||
You'll see the design, confirm, and services + agent start:
|
||||
|
||||
```text
|
||||
Services:
|
||||
● task — Core task management
|
||||
● project — Project organization
|
||||
|
||||
Generate? [Y/n]
|
||||
|
||||
Micro
|
||||
Services:
|
||||
● task
|
||||
● project
|
||||
Agents:
|
||||
◆ agent
|
||||
```
|
||||
|
||||
The interactive console lets you talk to your services immediately:
|
||||
|
||||
```text
|
||||
> Create a project called Launch, then add a task called 'Write docs'
|
||||
|
||||
→ project_Project_Create({"name":"Launch"})
|
||||
← {"record":{"id":"p1..."},"success":true}
|
||||
→ task_Task_Create({"title":"Write docs","project_id":"p1..."})
|
||||
|
||||
Created project Launch and added task 'Write docs' to it.
|
||||
```
|
||||
|
||||
The console discovers services from the registry and orchestrates across them via the agent. Use `micro run -d` for detached mode without the console, or `micro chat` as a standalone command.
|
||||
|
||||
### First-agent on-ramp
|
||||
|
||||
After this quick start, follow the agent path in order:
|
||||
|
||||
1. [Install troubleshooting](guides/install-troubleshooting.html) — verify the CLI install before agent work.
|
||||
2. `micro agent demo` — print the provider-free first-agent demo command and next docs steps from the installed CLI.
|
||||
3. `micro examples` — print the maintained provider-free runnable examples in copy/paste order.
|
||||
4. `micro zero-to-hero` — print the maintained one-command no-secret lifecycle harness and runnable examples.
|
||||
5. [Smallest first-agent example](https://github.com/micro/go-micro/tree/master/examples/first-agent) — run one service-backed agent with a mock model and no provider key.
|
||||
6. [No-secret first-agent transcript](guides/no-secret-first-agent.html) — run a useful support agent with a mock model before setting up a provider key.
|
||||
7. [Your First Agent](guides/your-first-agent.html) — build a service-backed agent and talk to it with `micro chat`.
|
||||
8. [Debugging your agent](guides/debugging-agents.html) — inspect service registration, tool calls, run history, memory, provider failures, and flow handoffs when the agent surprises you.
|
||||
9. [0→hero reference path](guides/zero-to-hero.html) — prove the full scaffold → run → chat → inspect → deploy dry-run lifecycle with commands exercised by `make harness`.
|
||||
1. [No-secret first-agent transcript](guides/no-secret-first-agent.html) — run a useful support agent with a mock model before setting up a provider key.
|
||||
2. [Your First Agent](guides/your-first-agent.html) — build a service-backed agent and talk to it with `micro chat`.
|
||||
3. [Debugging your agent](guides/debugging-agents.html) — inspect service registration, tool calls, run history, memory, provider failures, and flow handoffs when the agent surprises you.
|
||||
4. [0→hero reference path](guides/zero-to-hero.html) — prove the full scaffold → run → chat → inspect → deploy dry-run lifecycle with commands exercised by `make harness`.
|
||||
|
||||
## Write a Service
|
||||
## Quick Start: Write a Service
|
||||
|
||||
Create and run a service manually:
|
||||
|
||||
@@ -129,43 +159,6 @@ micro new events --template pubsub
|
||||
micro new gateway --template api
|
||||
```
|
||||
|
||||
|
||||
## Generate from a Prompt — with an LLM key
|
||||
|
||||
After the no-secret path works, set a provider key if you want Go Micro to design services and an agent from a prompt:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY=sk-ant-... # or OPENAI_API_KEY, GEMINI_API_KEY, ...
|
||||
micro run --prompt "task management system" --provider anthropic
|
||||
```
|
||||
|
||||
You'll see the design, confirm it, and then services plus an agent start:
|
||||
|
||||
```text
|
||||
Services:
|
||||
● task — Core task management
|
||||
● project — Project organization
|
||||
|
||||
Generate? [Y/n]
|
||||
|
||||
Micro
|
||||
Services:
|
||||
● task
|
||||
● project
|
||||
Agents:
|
||||
◆ agent
|
||||
```
|
||||
|
||||
Use the interactive console, `micro run -d` plus `micro chat`, or the agent playground to talk to the generated services.
|
||||
|
||||
Before your first provider-backed agent run, check the local path with:
|
||||
|
||||
```bash
|
||||
micro agent preflight
|
||||
```
|
||||
|
||||
The preflight is read-only: it verifies Go 1.24+, the `micro` binary, provider-key setup, and whether the default `micro run` gateway port is free, without calling an LLM provider. When a check fails it prints the exact fix plus the next guide to open, so the scaffold → run → chat path stays walkable.
|
||||
|
||||
## Building Agents
|
||||
|
||||
For a complete service-backed walkthrough, start with [Your First Agent](guides/your-first-agent.html). If you want to run before you write, use [`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support) for the full services → agents → workflows lifecycle or [`examples/agent-plan-delegate`](https://github.com/micro/go-micro/tree/master/examples/agent-plan-delegate) for the smallest multi-agent planning/delegation path.
|
||||
@@ -259,5 +252,4 @@ The flow discovers all services as tools and lets the LLM decide which RPCs to c
|
||||
- [Agent Design](https://github.com/micro/go-micro/blob/master/internal/docs/AGENT_DESIGN.md) — the full agent interface specification
|
||||
- [MCP & AI Agents](mcp.html) — MCP gateway, tool discovery, and auth
|
||||
- [Data Model](model.html) — typed persistence with CRUD and queries
|
||||
- [`micro loop` quickstart](guides/micro-loop.html) — scaffold a CI-gated autonomous improvement loop for a repository
|
||||
- [Deployment](deployment.html) — deploy via SSH + systemd
|
||||
|
||||
@@ -186,16 +186,6 @@ This is the JSON-RPC binding for task execution:
|
||||
|
||||
Both directions work: the gateway exposes your agents, and `a2a.Client` (via `flow.A2A` or `delegate` to a URL) calls external ones. The task binding is what makes a Go Micro agent both reachable from, and able to reach, the A2A ecosystem today.
|
||||
|
||||
## AP2 mandate layer (opt-in)
|
||||
|
||||
AP2 sits above A2A as a verifiable-intent and audit layer. Go Micro keeps the
|
||||
A2A envelope separate from payment settlement: an A2A message can carry signed
|
||||
AP2 checkout or payment mandates, and the resulting task can retain the stable
|
||||
mandate reference plus verification result. Payment settlement state remains in
|
||||
the payment rail. For x402, use an AP2 payment mandate with an `x402` rail
|
||||
reference to name the payment requirement; the existing x402 facilitator still
|
||||
performs verification and settlement.
|
||||
|
||||
## See also
|
||||
|
||||
- [MCP & AI Agents](../mcp.html) — exposing services as tools
|
||||
|
||||
@@ -38,7 +38,7 @@ The built-in providers currently register these capability interfaces:
|
||||
|
||||
| Provider | Chat/text (`ai.Model`) | Image (`ai.ImageModel`) | Video (`ai.VideoModel`) | Streaming (`ai.Stream`) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `anthropic` | Yes | No | No | Yes |
|
||||
| `anthropic` | Yes | No | No | No |
|
||||
| `atlascloud` | Yes | Yes | Yes | Yes |
|
||||
| `gemini` | Yes | No | No | No |
|
||||
| `groq` | Yes | No | No | Yes |
|
||||
|
||||
@@ -17,21 +17,9 @@ micro inspect ... # read the recorded run or workflow history
|
||||
|
||||
Debug the lifecycle in the same order Go Micro runs it: first prove the service is
|
||||
registered and callable, then inspect the agent run that chose tools, then inspect
|
||||
any workflow that handed off to the agent.
|
||||
|
||||
Use the recovery command that matches where you are in the first-agent journey:
|
||||
|
||||
| Checkpoint | When to use it | Command |
|
||||
| --- | --- | --- |
|
||||
| Install troubleshooting | `micro` is not installed, not on `PATH`, or the shell cannot run it. | [Install troubleshooting](install-troubleshooting.html) |
|
||||
| Preflight before `micro run` | You have not started the local runtime yet and want to verify Go, CLI, provider-key, and gateway-port prerequisites. | `micro agent preflight` |
|
||||
| Doctor after `micro run` | `micro run` is active, but chat, the `/agent` gateway, agent registration, provider settings, or inspect/run history is not behaving. | `micro agent doctor` |
|
||||
|
||||
`micro agent preflight` is read-only and runs before the first local run; failed
|
||||
checks include `Fix:` and `Next:` lines for Go, CLI installation, provider-key
|
||||
setup, and the local gateway port. Once `micro run` is already up, switch to
|
||||
`micro agent doctor` so the recovery output follows the live gateway, chat
|
||||
settings, registered agents, provider configuration, and inspectable run history.
|
||||
any workflow that handed off to the agent. If the first local run fails before a
|
||||
chat turn, run `micro agent preflight`; failed checks include `Fix:` and `Next:`
|
||||
lines for Go, CLI installation, provider-key setup, and the local gateway port.
|
||||
|
||||
## 1. Reproduce one small turn
|
||||
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
---
|
||||
layout: default
|
||||
title: Install troubleshooting
|
||||
---
|
||||
|
||||
# Install troubleshooting
|
||||
|
||||
Use this page before `micro new` or `micro agent demo` when the CLI install is
|
||||
unclear. The goal is to prove three boundaries in order: the `micro` binary is on
|
||||
`PATH`, it is the version you expected, and the no-secret first-run path works
|
||||
without provider keys.
|
||||
|
||||
## 1. Choose one install path
|
||||
|
||||
### Binary installer (no Go required to install)
|
||||
|
||||
```sh
|
||||
curl -fsSL https://go-micro.dev/install.sh | sh
|
||||
```
|
||||
|
||||
Use this when you want the released `micro` binary without building it yourself.
|
||||
The generated services still need a Go toolchain when you run `micro run`, but the
|
||||
installer itself does not require Go.
|
||||
|
||||
### Go install (build from source)
|
||||
|
||||
```sh
|
||||
go install go-micro.dev/v6/cmd/micro@latest
|
||||
```
|
||||
|
||||
Use this when Go is already installed and you want the binary in your Go bin
|
||||
directory. If the command succeeds but `micro` is not found, your Go bin directory
|
||||
is probably not on `PATH`.
|
||||
|
||||
## 2. Verify `PATH` and version
|
||||
|
||||
Check which binary your shell will run:
|
||||
|
||||
```sh
|
||||
command -v micro
|
||||
micro --version
|
||||
```
|
||||
|
||||
If `command -v micro` prints nothing, add the install directory to `PATH`, then
|
||||
open a new terminal and retry. Common locations are:
|
||||
|
||||
```sh
|
||||
export PATH="$HOME/.micro/bin:$PATH" # binary installer
|
||||
export PATH="$(go env GOPATH)/bin:$PATH" # go install
|
||||
```
|
||||
|
||||
If `micro --version` shows an older binary than expected, remove the stale copy or
|
||||
put the intended install directory earlier in `PATH`.
|
||||
|
||||
## 3. Run the no-secret smoke path
|
||||
|
||||
Once `micro` resolves, prove the local service runtime before adding LLM provider
|
||||
keys:
|
||||
|
||||
```sh
|
||||
micro new helloworld
|
||||
cd helloworld
|
||||
micro run
|
||||
```
|
||||
|
||||
In another terminal:
|
||||
|
||||
```sh
|
||||
curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call \
|
||||
-H 'Content-Type: application/json' -d '{"name":"World"}'
|
||||
```
|
||||
|
||||
This checks the scaffold, local build, gateway, and service registration without
|
||||
calling a model provider.
|
||||
|
||||
## 4. Recover common failures
|
||||
|
||||
| Symptom | Check | Fix |
|
||||
|---------|-------|-----|
|
||||
| `micro: command not found` | `command -v micro` | Add the installer bin directory or `$(go env GOPATH)/bin` to `PATH`, then open a new terminal. |
|
||||
| `micro run` cannot find Go | `go version` | Install Go 1.24 or newer from <https://go.dev/doc/install>. |
|
||||
| The gateway port is busy | `lsof -i :8080` | Stop the process using the port, or run with a different address. |
|
||||
| Provider-key errors block an agent run | `micro agent preflight` | Stay on the no-secret path first: run `micro agent demo`, then the no-secret first-agent guide. |
|
||||
|
||||
## 5. Continue the first-agent on-ramp
|
||||
|
||||
After install verification succeeds, continue in order:
|
||||
|
||||
1. `micro agent demo` — print the provider-free first-agent demo command and next docs steps.
|
||||
2. [No-secret first-agent transcript](no-secret-first-agent.html) — prove an agent can use services without a provider key.
|
||||
3. [Your First Agent](your-first-agent.html) — build and chat with your own service-backed agent.
|
||||
4. [Debugging your agent](debugging-agents.html) — inspect registration, tool calls, run history, and provider failures.
|
||||
5. [0→hero Reference](zero-to-hero.html) — walk the full services → agents → workflows lifecycle.
|
||||
|
||||
For repository contributors, `make install-smoke` runs the same installer seam
|
||||
against a local build without network access.
|
||||
@@ -1,96 +0,0 @@
|
||||
---
|
||||
layout: default
|
||||
---
|
||||
|
||||
# `micro loop` quickstart
|
||||
|
||||
`micro loop` scaffolds the autonomous improvement loop that Go Micro uses on
|
||||
this repository: GitHub Actions workflows for planning, building, evaluation
|
||||
feedback, coherence, security, and release. Use it when you want a repository to
|
||||
continuously turn a ranked queue into small PRs while CI remains the merge gate.
|
||||
|
||||
## 1. Initialize the loop
|
||||
|
||||
Run the default loop from the repository root:
|
||||
|
||||
```bash
|
||||
micro loop init
|
||||
```
|
||||
|
||||
For every role used by Go Micro itself, scaffold all workflows:
|
||||
|
||||
```bash
|
||||
micro loop init --roles all
|
||||
```
|
||||
|
||||
The command writes:
|
||||
|
||||
- `.github/loop/NORTH_STAR.md` — the direction every increment should optimize.
|
||||
- `.github/loop/PRIORITIES.md` — the ranked queue; the builder takes the top open issue.
|
||||
- `.github/loop/prompts/*.md` — editable policy for planner, builder, triage, coherence, and security roles.
|
||||
- `.github/workflows/loop-*.yml` — generated GitHub Actions mechanics.
|
||||
|
||||
Edit the files under `.github/loop/` to steer the loop. Re-run
|
||||
`micro loop init --roles all --force` only when you want to regenerate workflow
|
||||
mechanics from the installed CLI.
|
||||
|
||||
## 2. Configure the dispatch token
|
||||
|
||||
The scheduled builder needs a repository secret containing a token from a user
|
||||
account that the coding agent will answer. Go Micro names that secret
|
||||
`CODEX_TRIGGER_TOKEN` by default. If you use another secret name, pass it when
|
||||
you initialize the loop:
|
||||
|
||||
```bash
|
||||
micro loop init --agent @codex --token-secret LOOP_TOKEN --roles all
|
||||
```
|
||||
|
||||
The token needs enough repository permission to open issues, comment, push
|
||||
branches, create pull requests, and enable auto-merge. Run `gh auth setup-git` in
|
||||
the environment that will push branches so `git push` uses the same credentials
|
||||
as `gh`.
|
||||
|
||||
## 3. Make CI the gate
|
||||
|
||||
The loop should not be its own reviewer. Protect the default branch so PRs merge
|
||||
only after the required checks pass. At minimum, require the same commands the
|
||||
Go Micro loop verifies locally and in CI:
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
go test ./...
|
||||
golangci-lint run ./...
|
||||
```
|
||||
|
||||
If your repository has a harness or end-to-end grader, make that required too.
|
||||
Keep human approval requirements out of the autonomous path unless you intend the
|
||||
loop to pause for review.
|
||||
|
||||
## 4. Verify the wiring
|
||||
|
||||
After editing the North Star, queue, prompts, token secret, and branch
|
||||
protection, run:
|
||||
|
||||
```bash
|
||||
micro loop verify
|
||||
```
|
||||
|
||||
`micro loop verify` checks that the loop direction, queue, prompts, role
|
||||
workflows, and non-loop CI gate are present. Fix any reported missing items
|
||||
before relying on scheduled increments.
|
||||
|
||||
## 5. Operate the queue
|
||||
|
||||
Keep one ranked list in `.github/loop/PRIORITIES.md`. Each item should link a
|
||||
scoped issue and be small enough for one PR. The builder closes both the priority
|
||||
issue and the per-run tracker issue in the PR body, for example:
|
||||
|
||||
```text
|
||||
Closes #1234
|
||||
Closes #5678
|
||||
```
|
||||
|
||||
Use the North Star to keep the queue honest: favor small improvements that move
|
||||
developers through the services → agents → workflows lifecycle, and surface
|
||||
breaking API or brand/positioning decisions for humans instead of auto-merging
|
||||
them.
|
||||
@@ -25,23 +25,11 @@ end to end with no secrets.
|
||||
|
||||
## Transcript
|
||||
|
||||
If you installed the CLI first, ask it for the no-secret path:
|
||||
|
||||
```sh
|
||||
micro agent demo
|
||||
```
|
||||
|
||||
From a fresh clone of the repository, first run the smallest service-backed agent:
|
||||
From a fresh clone of the repository:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/micro/go-micro.git
|
||||
cd go-micro
|
||||
go run ./examples/first-agent
|
||||
```
|
||||
|
||||
Then run the maintained support-agent transcript that exercises the full lifecycle:
|
||||
|
||||
```sh
|
||||
go run ./examples/support
|
||||
```
|
||||
|
||||
@@ -68,10 +56,9 @@ trigger and inspect the work.
|
||||
|
||||
## CI-backed check
|
||||
|
||||
Run the same deterministic paths as focused tests:
|
||||
Run the same deterministic path as a focused test:
|
||||
|
||||
```sh
|
||||
go test ./examples/first-agent -run TestRunFirstAgent -count=1
|
||||
go test ./examples/support -run TestRunSupportMockSmoke -count=1
|
||||
```
|
||||
|
||||
@@ -100,29 +87,8 @@ CI keeps those CLI boundaries present with:
|
||||
|
||||
```sh
|
||||
go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1
|
||||
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1
|
||||
```
|
||||
|
||||
## Debug transcript checkpoint
|
||||
|
||||
A successful first chat turn should always leave an inspectable trail. After the
|
||||
chat command finishes, continue the same terminal transcript with the inspection
|
||||
and history commands before changing prompts or provider settings:
|
||||
|
||||
```sh
|
||||
micro chat assistant --prompt "Triage ticket-1 for Alice"
|
||||
micro inspect agent assistant --limit 1
|
||||
micro agent history assistant
|
||||
```
|
||||
|
||||
The inspection output is the checkpoint that the runnable loop did not stop at
|
||||
chat: it should show a recent agent run with a status, event count, last event,
|
||||
and trace breadcrumb when tracing is configured. `micro agent history assistant`
|
||||
then confirms the conversation memory that future turns will reuse. If either
|
||||
command is empty after a successful chat turn, keep the failing transcript and
|
||||
use [Debugging your agent](debugging-agents.html) to check provider failures, run
|
||||
history, memory, and tool-call inspection before changing application code.
|
||||
|
||||
If `micro agent preflight` reports a missing provider key, you can still use this no-secret path because it runs against the mock model; the command now prints this guide as the next step for that failure. If chat behaves unexpectedly, continue to
|
||||
[Debugging your agent](debugging-agents.html) for provider checks, run history,
|
||||
memory, and tool-call inspection.
|
||||
|
||||
@@ -57,7 +57,7 @@ previous section.
|
||||
|
||||
| Provider | Chat/text agent harness | Image | Video | Streaming | Structured errors |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `anthropic` | ✅ Verified when configured | — Unsupported | — Unsupported | ✅ Verified when configured | ⚠️ Unverified |
|
||||
| `anthropic` | ✅ Verified when configured | — Unsupported | — Unsupported | ⚠️ Unverified | ⚠️ Unverified |
|
||||
| `openai` | ✅ Verified when configured | ✅ Registered | — Unsupported | ⚠️ Unverified | ⚠️ Unverified |
|
||||
| `gemini` | ✅ Verified when configured | — Unsupported | — Unsupported | ⚠️ Unverified | ⚠️ Unverified |
|
||||
| `groq` | ✅ Verified when configured | — Unsupported | — Unsupported | ⚠️ Unverified | ⚠️ Unverified |
|
||||
|
||||
@@ -128,11 +128,3 @@ Leave those variables unset in normal CI; the live test skips unless the facilit
|
||||
- [Building Effective Agents — Agents and Workflows](agents-and-workflows.html)
|
||||
- [MCP & AI Agents](../mcp.html)
|
||||
- [x402 — Coinbase Developer Docs](https://docs.cdp.coinbase.com/x402/welcome) · [x402 on Solana](https://solana.com/x402/what-is-x402)
|
||||
|
||||
## AP2 payment mandates
|
||||
|
||||
AP2 can authorize an x402 payment without making A2A carry settlement state. A
|
||||
payment mandate records the buyer intent and names an `x402` rail reference; the
|
||||
existing x402 facilitator remains responsible for payment verification and
|
||||
settlement. This keeps AP2 as the signed mandate/audit layer while x402 stays the
|
||||
pluggable payment rail.
|
||||
|
||||
@@ -47,17 +47,13 @@ export ANTHROPIC_API_KEY=sk-ant-...
|
||||
Plain service calls work without a model key; the key is only needed when the
|
||||
agent reasons over tools.
|
||||
|
||||
Run the read-only first-agent preflight before starting the walkthrough. The same CLI boundary is covered by CI with `go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1`, and the copy/paste tutorial code is built from a clean temporary workspace with `go test ./internal/harness/zero-to-hero-ci -run TestYourFirstAgentTutorialSmoke -count=1`, so the documented scaffold → run → chat → inspect path stays visible in the local harness:
|
||||
Run the read-only first-agent preflight before starting the walkthrough. The same CLI boundary is covered by CI with `go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1`, so the documented scaffold → run → chat → inspect path stays visible in the local harness:
|
||||
|
||||
```sh
|
||||
micro agent preflight
|
||||
```
|
||||
|
||||
It checks Go 1.24+, the `micro` binary, provider-key setup, and the default local gateway port without contacting a provider. Failed checks include a `Fix:` line and a `Next:` line that points back to this guide, the no-secret walkthrough, or the debugging guide. Use it before `micro run`; if `micro run` is already active but `micro chat`, the `/agent` gateway, registration, provider settings, or inspect history is failing, run the after-run recovery check instead:
|
||||
|
||||
```sh
|
||||
micro agent doctor
|
||||
```
|
||||
It checks Go 1.24+, the `micro` binary, provider-key setup, and the default local gateway port without contacting a provider. Failed checks include a `Fix:` line and a `Next:` line that points back to this guide, the no-secret walkthrough, or the debugging guide.
|
||||
|
||||
## 1. Create a workspace
|
||||
|
||||
@@ -177,14 +173,7 @@ Create a task called "Review the first-agent walkthrough", then show me all task
|
||||
```
|
||||
|
||||
A healthy run shows the agent calling the task service and then summarizing the
|
||||
result. Inspect the recorded run when you want to see the tool calls, memory,
|
||||
and timing behind the answer:
|
||||
|
||||
```sh
|
||||
micro inspect agent assistant
|
||||
```
|
||||
|
||||
If the model refuses to call tools, tighten the prompt so it explicitly
|
||||
result. If the model refuses to call tools, tighten the prompt so it explicitly
|
||||
uses the `task` service before answering.
|
||||
|
||||
## 4. Know what just happened
|
||||
|
||||
@@ -19,34 +19,17 @@ cloud credentials?"
|
||||
| --- | --- | --- |
|
||||
| Scaffold | `micro new` generates a runnable service with and without MCP support. | `go test ./cmd/micro/cli/new -run TestZeroToOne -count=1` |
|
||||
| First-agent wayfinding | README and the website getting-started docs keep the no-secret → first-agent → debugging → 0→hero links present and in order. | `go test ./internal/harness/zero-to-hero-ci -run TestFirstAgentWayfindingDocs -count=1` |
|
||||
| First agent | `micro new`, `micro agent preflight`, `micro run`, `micro chat`, and `micro inspect agent <name>` stay available for the documented first-agent walkthrough. | `go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1` |
|
||||
| First agent | `micro new`, `micro agent preflight`, `micro run`, `micro chat`, and `micro inspect agent` stay available for the documented first-agent walkthrough. | `go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1` |
|
||||
| Run | `micro run` remains the local development entry point. | `go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1` |
|
||||
| Chat | `micro chat` remains the interactive agent entry point. | `go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1` |
|
||||
| Inspect | `micro inspect agent <name>`, `micro agent history <name>`, `micro inspect flow <flow>`, and `micro flow runs <flow>` remain discoverable for run history; the no-secret debugging smoke seeds durable agent history and runs the documented inspect/history commands without provider keys. | `go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1` |
|
||||
| Inspect | `micro inspect agent`, `micro inspect flow`, and `micro flow runs` remain discoverable for run history. | `go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1` |
|
||||
| Deploy | `micro deploy --dry-run` resolves deploy targets without touching remote infrastructure. | `go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1` |
|
||||
| Smallest first agent | `examples/first-agent` runs one service-backed agent with a deterministic mock model and no provider key. | `go test ./examples/first-agent -run TestRunFirstAgent -count=1` |
|
||||
| Runtime reference app | `examples/support` runs typed services, an agent using those services as tools, an event-driven flow handoff, and an approval gate with only the model mocked. | `go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1` |
|
||||
| Runtime harnesses | Real services, agents, durable flows, store-backed history, delegation, and A2A run with only the model mocked. | `./internal/harness/zero-to-hero-ci/run.sh` and `make provider-conformance-mock` |
|
||||
|
||||
## Find the one-command entrypoint
|
||||
|
||||
After installing the CLI, ask `micro` for the maintained no-secret lifecycle command:
|
||||
|
||||
```sh
|
||||
micro zero-to-hero
|
||||
```
|
||||
|
||||
The command prints the exact harness command below plus the smaller runnable examples, so a new developer can discover the 0→hero path from CLI help instead of translating this guide by hand.
|
||||
|
||||
## Run the runnable example
|
||||
|
||||
From the repository root, start with the smallest service-backed agent when you want the fastest no-secret success path:
|
||||
|
||||
```sh
|
||||
go run ./examples/first-agent
|
||||
```
|
||||
|
||||
Then run the support-desk example when you want to see the full lifecycle in one terminal:
|
||||
From the repository root, start with the support-desk example when you want to see the full lifecycle in one terminal:
|
||||
|
||||
```sh
|
||||
go run ./examples/support
|
||||
@@ -85,9 +68,6 @@ go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1
|
||||
go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1
|
||||
go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1
|
||||
|
||||
# Smallest no-secret service-backed first agent.
|
||||
go test ./examples/first-agent -run TestRunFirstAgent -count=1
|
||||
|
||||
# Maintained 0→hero support-desk reference app.
|
||||
go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1
|
||||
|
||||
@@ -103,9 +83,6 @@ make provider-conformance-mock
|
||||
|
||||
## Reference scenarios
|
||||
|
||||
- [`examples/first-agent`](https://github.com/micro/go-micro/tree/master/examples/first-agent)
|
||||
is the smallest no-secret service-backed agent: one notes service, one scoped
|
||||
assistant agent, and a deterministic mock model.
|
||||
- [`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support)
|
||||
is the runnable support-desk story: customers, tickets, notify, a support
|
||||
agent, an intake flow, and an approval gate in one no-secret example.
|
||||
|
||||
@@ -16,7 +16,7 @@ It's built on a pluggable architecture of Go interfaces: service discovery, clie
|
||||
|
||||
## Learn More
|
||||
|
||||
Start with [Getting Started](getting-started.html) for install and the first local service. Then follow the first-agent on-ramp: `micro agent demo` for the installed no-secret CLI affordance, `micro examples` for copy/pasteable runnable examples, [No-secret first-agent transcript](guides/no-secret-first-agent.html) to run a mock-model support agent, [Your First Agent](guides/your-first-agent.html) to build and chat with a service-backed agent, [Debugging your agent](guides/debugging-agents.html) to inspect runs and memory, and the [0→hero reference path](guides/zero-to-hero.html) to walk the full scaffold → run → chat → inspect → deploy dry-run lifecycle covered by CI.
|
||||
Start with [Getting Started](getting-started.html) for install and the first local service. Then follow the first-agent on-ramp: [No-secret first-agent transcript](guides/no-secret-first-agent.html) to run a mock-model support agent, [Your First Agent](guides/your-first-agent.html) to build and chat with a service-backed agent, [Debugging your agent](guides/debugging-agents.html) to inspect runs and memory, and the [0→hero reference path](guides/zero-to-hero.html) to walk the full scaffold → run → chat → inspect → deploy dry-run lifecycle covered by CI.
|
||||
|
||||
Otherwise continue to read the docs for more information about the framework.
|
||||
|
||||
@@ -24,13 +24,10 @@ Otherwise continue to read the docs for more information about the framework.
|
||||
|
||||
- [Getting Started](getting-started.html)
|
||||
- [0→hero Reference](guides/zero-to-hero.html) - Walk scaffold → run → chat → inspect → deploy dry-run with CI-backed commands
|
||||
- `micro agent demo` - Show the provider-free first-agent demo command and next docs steps
|
||||
- `micro examples` - Show provider-free first-agent examples in copy/paste order
|
||||
- [No-secret first-agent transcript](guides/no-secret-first-agent.html) - Run the first useful agent path without a provider key
|
||||
- [Your First Agent](guides/your-first-agent.html) - Build a service-backed agent end to end
|
||||
- [MCP & AI Agents](mcp.html) - Turn services into AI-callable tools with the Model Context Protocol
|
||||
- [CLI & Gateway Guide](guides/cli-gateway.html) - Development vs Production modes
|
||||
- [`micro loop` quickstart](guides/micro-loop.html) - Scaffold an autonomous CI-gated improvement loop
|
||||
- [Quick Start](quickstart.html)
|
||||
- [Architecture](architecture.html)
|
||||
- [Configuration](config.html)
|
||||
@@ -65,6 +62,5 @@ Otherwise continue to read the docs for more information about the framework.
|
||||
- [Real-World Examples](examples/realworld/)
|
||||
- [Migration Guides](guides/migration/)
|
||||
- [Observability](observability.html)
|
||||
- [`micro loop` quickstart](guides/micro-loop.html)
|
||||
- [Contributing](contributing.html)
|
||||
- [Roadmap](roadmap.html)
|
||||
|
||||
@@ -16,8 +16,6 @@ Or, if you have Go and prefer to build from source:
|
||||
go install go-micro.dev/v6/cmd/micro@latest
|
||||
```
|
||||
|
||||
If the installer finishes but your shell cannot find `micro`, open [Install troubleshooting](guides/install-troubleshooting.html) before creating your first service.
|
||||
|
||||
## Create Your First Service
|
||||
|
||||
```bash
|
||||
@@ -41,15 +39,9 @@ curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call \
|
||||
|
||||
You now have the service half of the services → agents → workflows lifecycle running locally. Keep the on-ramp going in this order:
|
||||
|
||||
1. **[Install troubleshooting](guides/install-troubleshooting.html)** - verify the binary installer or `go install`, `PATH`, `micro --version`, and the no-secret smoke path.
|
||||
2. `micro agent demo` - print the provider-free first-agent demo command and the next docs steps from the installed CLI.
|
||||
3. `micro examples` - print the maintained provider-free runnable examples in copy/paste order.
|
||||
4. `micro zero-to-hero` - print the maintained one-command no-secret lifecycle harness and runnable examples.
|
||||
5. **[Smallest first-agent example](https://github.com/micro/go-micro/tree/master/examples/first-agent)** - run a mock-model, no-secret agent before adding provider keys.
|
||||
6. **[No-secret first-agent transcript](guides/no-secret-first-agent.html)** - run a useful support agent with a mock model before setting up a provider key.
|
||||
7. **[Your First Agent](guides/your-first-agent.html)** - turn this service into an agent-callable tool, chat with it, and learn the `micro agent preflight` → `micro run` → `micro chat` loop.
|
||||
8. **[Debugging your agent](guides/debugging-agents.html)** - inspect service registration, tool calls, run history, memory, provider failures, and flow handoffs when the agent does something surprising.
|
||||
9. **[0→hero Reference](guides/zero-to-hero.html)** - walk the maintained scaffold → run → chat → inspect → deploy dry-run path that proves services, agents, and workflows together.
|
||||
1. **[Your First Agent](guides/your-first-agent.html)** - turn this service into an agent-callable tool, chat with it, and learn the `micro agent preflight` → `micro run` → `micro chat` loop.
|
||||
2. **[Debugging your agent](guides/debugging-agents.html)** - inspect service registration, tool calls, run history, memory, provider failures, and flow handoffs when the agent does something surprising.
|
||||
3. **[0→hero Reference](guides/zero-to-hero.html)** - walk the maintained scaffold → run → chat → inspect → deploy dry-run path that proves services, agents, and workflows together.
|
||||
|
||||
After that first-agent path, branch out to:
|
||||
|
||||
@@ -64,11 +56,7 @@ After that first-agent path, branch out to:
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go-micro.dev/v6"
|
||||
)
|
||||
import "go-micro.dev/v6"
|
||||
|
||||
type Greeter struct{}
|
||||
|
||||
@@ -86,11 +74,7 @@ func main() {
|
||||
|
||||
### Pub/Sub Event Handler
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go-micro.dev/v6"
|
||||
)
|
||||
import "go-micro.dev/v6"
|
||||
|
||||
func main() {
|
||||
service := micro.NewService("subscriber")
|
||||
@@ -98,7 +82,7 @@ func main() {
|
||||
// Subscribe to events
|
||||
micro.RegisterSubscriber("user.created", service.Server(),
|
||||
func(ctx context.Context, event *UserCreatedEvent) error {
|
||||
// Handle the event here.
|
||||
log.Infof("User created: %s", event.Email)
|
||||
return nil
|
||||
},
|
||||
)
|
||||
@@ -120,3 +104,4 @@ publisher.Publish(ctx, &UserCreatedEvent{
|
||||
- **[Discord Community](https://discord.gg/G8Gk5j3uXr)** - Chat with other users
|
||||
- **[GitHub Issues](https://github.com/micro/go-micro/issues)** - Report bugs or request features
|
||||
- **[Documentation](https://go-micro.dev/docs/)** - Complete docs
|
||||
|
||||
|
||||
@@ -220,13 +220,6 @@ func AgentResume(ctx context.Context, a Agent, runID string) (*AgentResponse, er
|
||||
return agent.Resume(ctx, a, runID)
|
||||
}
|
||||
|
||||
// AgentResumePending resumes every incomplete checkpointed agent run, oldest
|
||||
// first. It returns the first run id that fails again so startup recovery loops
|
||||
// can leave the durable backlog visible instead of swallowing the failure.
|
||||
func AgentResumePending(ctx context.Context, a Agent) (string, error) {
|
||||
return agent.ResumePending(ctx, a)
|
||||
}
|
||||
|
||||
// AgentResumeInput resumes a checkpointed agent run waiting for human input.
|
||||
func AgentResumeInput(ctx context.Context, a Agent, runID, input string) (*AgentResponse, error) {
|
||||
return agent.ResumeInput(ctx, a, runID, input)
|
||||
|
||||
Reference in New Issue
Block a user