Compare commits

..

3 Commits

Author SHA1 Message Date
Milos Gajdos e8e5871521 heck cache status error
(cherry picked from commit fc89c9831e)
2019-10-12 12:07:11 +01:00
Asim Aslam c8d71e051a Fix race in cache 2019-10-07 09:09:39 +01:00
Asim Aslam 6734f3800b fix major deadlock in registry cache 2019-10-07 09:09:31 +01:00
1192 changed files with 34992 additions and 129306 deletions
-31
View File
@@ -1,31 +0,0 @@
# EditorConfig for go-micro
# https://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.go]
indent_style = tab
indent_size = 4
[*.{yml,yaml}]
indent_style = space
indent_size = 2
[*.{json,proto}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
indent_style = space
indent_size = 2
[Makefile]
indent_style = tab
-2
View File
@@ -1,2 +0,0 @@
github: asim
custom: ["https://go-micro.dev/support"]
-51
View File
@@ -1,51 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: '[BUG] '
labels: bug
assignees: ''
---
## Describe the bug
A clear and concise description of what the bug is.
## To Reproduce
Steps to reproduce the behavior:
1. Create service with '...'
2. Configure plugin '...'
3. Run command '...'
4. See error
## Expected behavior
A clear and concise description of what you expected to happen.
## Code sample
```go
// Minimal reproducible code
```
## Environment
- Go Micro version: [e.g. v5.3.0]
- Go version: [run `go version`]
- OS/Platform: [e.g. Ubuntu 22.04, macOS 14, Docker]
- Plugins/Integrations: [e.g. consul registry, nats broker, redis cache]
## Logs
```
Paste relevant logs here (use -v flag for verbose output)
```
## Checklist
- [ ] I've searched existing issues and this is not a duplicate
- [ ] I've provided a minimal code sample that reproduces the issue
- [ ] I've included my environment details
- [ ] I've checked the documentation
## Additional context
Add any other context about the problem here.
## Helpful Resources
- [Troubleshooting Guide](https://github.com/micro/go-micro/tree/master/internal/website/docs/getting-started.md)
- [Examples](https://github.com/micro/go-micro/tree/master/examples)
- [API Reference](https://pkg.go.dev/go-micro.dev/v5)
- [Discord Community](https://discord.gg/G8Gk5j3uXr)
@@ -1,23 +0,0 @@
---
name: Commercial Support / Consulting
about: Inquire about paid support, consulting, training, or a retainer
title: '[SUPPORT] '
labels: commercial-support
assignees: asim
---
## What are you building?
A short description of your project and how you're using (or planning to use) Go Micro.
## What do you need?
- [ ] Production support / retainer (priority fixes, direct line, response SLA)
- [ ] Consulting (integration, architecture, agent design)
- [ ] Training / onboarding for a team
- [ ] Sponsored feature or fix
- [ ] Not sure yet — let's talk
## Scale & timeline
Team size, where you're running it, and any timeline that matters.
## Anything else?
Links, context, constraints. For anything you'd rather keep private, become a [sponsor](https://github.com/sponsors/asim) and message directly.
-8
View File
@@ -1,8 +0,0 @@
blank_issues_enabled: true
contact_links:
- name: 💖 Sponsor Go Micro
url: https://github.com/sponsors/asim
about: Fund ongoing development and see your name or logo on the project.
- name: 📖 Documentation
url: https://go-micro.dev/docs
about: Guides, examples, and the full reference.
-42
View File
@@ -1,42 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: '[FEATURE] '
labels: enhancement
assignees: ''
---
## Is your feature request related to a problem?
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
## Describe the solution you'd like
A clear and concise description of what you want to happen.
## Describe alternatives you've considered
A clear and concise description of any alternative solutions or features you've considered.
## Use case
Describe how this feature would be used in practice. What problem does it solve?
**Example:**
```go
// Show how the feature would be used
```
## Implementation ideas (optional)
If you have thoughts on how this could be implemented, share them here.
## Additional context
Add any other context, code examples, or screenshots about the feature request here.
## Checklist
- [ ] I've searched existing issues and this is not a duplicate
- [ ] I've checked the roadmap and this isn't already planned
- [ ] I've provided a clear use case
- [ ] I'd be willing to submit a PR for this feature (optional)
## Helpful Resources
- [Roadmap](https://github.com/micro/go-micro/blob/master/ROADMAP.md)
- [Contributing Guide](https://github.com/micro/go-micro/blob/master/CONTRIBUTING.md)
- [Architecture Docs](https://github.com/micro/go-micro/tree/master/internal/website/docs/architecture.md)
- [Discord Community](https://discord.gg/G8Gk5j3uXr)
-61
View File
@@ -1,61 +0,0 @@
---
name: Performance issue
about: Report a performance problem or regression
title: '[PERFORMANCE] '
labels: performance
assignees: ''
---
## Performance Issue
**Symptom:**
Describe the performance problem (e.g., high latency, memory leak, CPU usage)
**Expected Performance:**
What performance did you expect?
## Benchmarks
Please provide benchmarks or profiling data:
```bash
# CPU profiling
go test -cpuprofile=cpu.prof -bench=.
# Memory profiling
go test -memprofile=mem.prof -bench=.
# Results
```
**Before/After comparison (if applicable):**
- Before: X req/sec, Y ms latency
- After: X req/sec, Y ms latency
## Code Sample
```go
// Minimal code that demonstrates the performance issue
```
## Environment
- Go Micro version: [e.g. v5.3.0]
- Go version: [run `go version`]
- Hardware: [e.g. 4 CPU, 8GB RAM]
- OS: [e.g. Ubuntu 22.04]
- Load: [e.g. 1000 req/sec, 100 concurrent connections]
## Profiling Data
Attach pprof profiles if available:
- CPU profile
- Memory profile
- Goroutine dump
## Additional Context
Add any other context about the performance issue.
## Resources
- [Performance Guide](https://github.com/micro/go-micro/tree/master/internal/website/docs/performance.md)
- [Benchmarking](https://pkg.go.dev/testing#hdr-Benchmarks)
-31
View File
@@ -1,31 +0,0 @@
---
name: Question
about: Ask a question about using Go Micro
title: '[QUESTION] '
labels: question
assignees: ''
---
## Your question
A clear and concise question about Go Micro usage.
## What have you tried?
Describe what you've already attempted or researched.
## Code sample (if applicable)
```go
// Your code here
```
## Context
Provide any additional context that might help answer your question.
## Resources you've checked
- [ ] [Getting Started Guide](https://github.com/micro/go-micro/tree/master/internal/website/docs/getting-started.md)
- [ ] [Examples](https://github.com/micro/go-micro/tree/master/internal/website/docs/examples)
- [ ] [API Documentation](https://pkg.go.dev/go-micro.dev/v5)
- [ ] Searched existing issues
## Helpful links
- [Documentation](https://github.com/micro/go-micro/tree/master/internal/website/docs)
- [Plugins Guide](https://github.com/micro/go-micro/tree/master/internal/website/docs/plugins.md)
-32
View File
@@ -1,32 +0,0 @@
# North Star
The direction the loop aligns every increment to. Depth lives in
[`internal/docs/THESIS.md`](../../internal/docs/THESIS.md); this is the short,
operative version the planner and builder read each run.
## Mission
Make building an **agent** as easy as building a **service**, on one runtime.
Go Micro is a holistic agent harness and service framework encapsulating the
lifecycle of **services → agents → workflows** — pluggable, progressive, and
AI-native by default.
## Right now — developer adoption
The framework's depth is strong; the **on-ramp** is the gap. Weight the developer
experience — a walkable first-agent tutorial, discoverable examples, docs
wayfinding, install friction, debugging, the 0→1 and 0→hero path — **at least as
highly as internal hardening**. A developer succeeding on their first agent
matters more right now than another conformance/observability/interop increment.
Do not let the queue fill entirely with internal depth work.
## Guardrails
- One concern per PR; small and reversible.
- The gate is green CI (`go build`, `go test`, `golangci-lint`, `make harness`),
not human review — keep the suite strong; the loop is only as good as its evaluator.
- **Off-limits without a human** (surface as notes, never auto-merge): breaking
public-API changes, brand/positioning/marketing copy, new dependencies,
architectural rewrites, product-default changes with broad behavioral impact.
- Stay on `claude/*` / `codex/*` branches; base PRs on `master`. See
[`CODEX.md`](../../CODEX.md) and [`internal/docs/CONTINUOUS_IMPROVEMENT.md`](../../internal/docs/CONTINUOUS_IMPROVEMENT.md).
-30
View File
@@ -1,30 +0,0 @@
# Priorities
The ranked work queue for the autonomous improvement loop. The
**architecture-review** pass (the *architect*) owns this file: each run it turns
the [roadmap](../../ROADMAP.md) plus an internal scan (gaps in the
services → agents → workflows lifecycle, API coherence, drift, tech debt, test and
DX friction) into a single ordered list — highest-value first — and links each
item to a tracking issue. The hourly **continuous-improvement** pass works the
**top item whose issue is still open**. So the architect decides *what*, and the
increment loop *builds* it.
**Reading / editing.** An item is done when its linked issue closes (the increment
that builds it adds `Closes #<issue>`). Roadmap phase (Now → Next → Later) is the
primary ordering; internal findings are interleaved by value, not kept in a
separate list. The human can reorder this list — or the issues — at any time to
redirect the loop; direction always wins.
**Off-limits to the loop** (the architect proposes these as notes, never as queue
items the loop can auto-merge): brand/positioning copy, breaking public-API
changes, architectural rewrites. Those go to the human.
## Work queue (ranked)
1. **Make AtlasCloud conformance deterministically request the echo tool** ([#3924](https://github.com/micro/go-micro/issues/3924)) — #3926 shipped the previous plan/delegate notify wait fix, so the queue should drop the completed #3918 item. The freshest live harness signal now fails even earlier: AtlasCloud/minimax-m3 completes without invoking the required `conformance_echo` tool. This is the highest Now-phase item because the getting-started and 0→hero contract depends on providers actually using service tools, not just returning prose, and the CI gate needs an actionable deterministic failure path.
2. **Make AtlasCloud conformance deterministically exercise guarded delegation** ([#3917](https://github.com/micro/go-micro/issues/3917)) — after the echo-tool path is stable, the same live provider must attempt the blocked delegate path so guardrails are proven under a real model, not only mocks. This keeps plan/delegate safety provider-portable without public API changes and protects the services → agents → workflows story from drifting at the delegation seam.
3. **Broaden provider streaming and keep chat/A2A streaming end to end** ([#3903](https://github.com/micro/go-micro/issues/3903)) — once the live gate is stable, streaming is the highest developer-visible Next-phase seam. Real chat and long-running A2A tasks need token streaming to stay coherent from provider → `ai.Stream``micro chat` → A2A `message/stream`, with mock/default CI coverage plus key-gated live provider checks and safe fallback for non-streaming providers.
4. **Trace agent runs as OpenTelemetry spans** ([#3908](https://github.com/micro/go-micro/issues/3908)) — the blog/README/roadmap story promises an operable harness, and the developer on-ramp now includes chat, inspect, and run-history checkpoints. The next observability gap is production-grade trace correlation for `RunInfo`: steps, tool calls, delegation, status, durations, and failures should be visible as spans while defaulting to no-op when tracing is not configured.
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
architecture-review pass._
-14
View File
@@ -1,14 +0,0 @@
<!--
The BUILDER prompt — go-micro's continuous-improvement increment. Editable
policy; the workflow prepends the agent @mention and substitutes __ISSUE__
before posting. Keep __ISSUE__ literal.
-->
Run one continuous-improvement increment per `internal/docs/CONTINUOUS_IMPROVEMENT.md`, aligned to the North Star in `.github/loop/NORTH_STAR.md` (the services → agents → workflows lifecycle, with developer adoption as the current goal).
PICK THE WORK FROM THE QUEUE: read `.github/loop/PRIORITIES.md` and take the highest-ranked item whose linked issue is still OPEN — that is your task, and its issue number is the one you close. If `PRIORITIES.md` is missing or every listed item's issue is already closed, fall back to the single highest-value roadmap / open-issue / improvement-radar item yourself.
Implement it, and VERIFY `go build ./...`, `go test ./...`, and `golangci-lint run ./...`.
Open the PR YOURSELF from the shell — do NOT use the make_pr tool (in this environment it only records metadata and never creates a PR). Create a uniquely-named branch under the `codex/` prefix: `git switch -c codex/increment-__ISSUE__`, then `git push -u origin codex/increment-__ISSUE__`, then `gh pr create --base master --label codex --title "<title>" --body "<body; include 'Closes #<the priority issue you built>' so it leaves the queue, and 'Closes #__ISSUE__' for this run's tracker>"`. Finally enable auto-merge so GitHub merges it once CI is green: `gh pr merge --squash --auto --delete-branch`.
One concern per PR. Stay out of breaking public API and brand/positioning copy — surface those as notes for the human instead.
-14
View File
@@ -1,14 +0,0 @@
<!--
The COHERENCE prompt — go-micro's DevRel pass (public-surface coherence +
CHANGELOG upkeep + changelog blog). Editable policy; the workflow prepends the
agent @mention and substitutes __ISSUE__ before posting. Keep __ISSUE__ literal.
-->
Act as DevRel for go-micro. Do these, in order.
COHERENCE AUDIT. Audit the public surface — `README.md`, `internal/website/` (landing `index.html` + `docs/`), and the blog under `internal/website/blog/` — for coherence with the North Star in `.github/loop/NORTH_STAR.md` (an agent harness and service framework; the services → agents → workflows lifecycle). Look for: places where README / website / docs contradict each other, are stale, or describe behavior that has since changed (cross-check against the code and recently merged PRs); whether the README is crisp and leads with the harness positioning; and one to three genuinely blog-worthy items from recently shipped work.
CHANGELOG UPKEEP (safe factual task — goes in the auto-merged PR). Keep `CHANGELOG.md` living, in Keep-a-Changelog format with newest content at the top under `## [Unreleased]`. Enumerate PRs merged to master since the last update (`gh pr list --state merged --base master --limit 60 --json number,title,mergedAt,labels`) and add a concise, user-facing entry for each genuine change not yet recorded under the right `### Added` / `### Changed` / `### Fixed` / `### Documentation` subheading — SKIP internal loop/CI/priorities-refresh churn. If a new `vX.Y.Z` tag was cut since the last run (`git fetch --tags --force`), rename `## [Unreleased]` to `## [X.Y.Z] - <Month YYYY>` and open a fresh empty `## [Unreleased]` above it. Do not invent entries.
CHANGELOG BLOG POST (blog voice — do NOT auto-merge). If, and only if, enough user-facing work has accumulated since the last changelog post to be worth reading (roughly a week's worth; not a near-empty post every day), draft a short "What's new in Go Micro" post as the next-numbered file in `internal/website/blog/`, mirroring the latest post's frontmatter and prev-nav, and add an entry at the top of `internal/website/blog/index.html`. Base it strictly on the CHANGELOG.
THEN: (A) post a findings report as a comment on this issue (#__ISSUE__) — what's aligned, what drifted, what you fixed, the CHANGELOG entries added, and whether you drafted a blog post (and why/why not). (B) Open ONE auto-merging PR for the SAFE factual work only — coherence/crispness fixes AND the CHANGELOG update (NOT brand/positioning rewrites, NOT the blog post): `git switch -c codex/coherence-__ISSUE__`, `git push -u origin codex/coherence-__ISSUE__`, `gh pr create --base master --label codex --title "<title>" --body "<summary, Closes #__ISSUE__>"`, then `gh pr merge --squash --auto --delete-branch`. (C) If you drafted a changelog blog post, open it as a SEPARATE PR (`codex/coherence-blog-__ISSUE__`, title prefixed `blog:`) and do NOT enable auto-merge — leave it for the human. Same for any brand/positioning copy. Do not use the make_pr tool.
-16
View File
@@ -1,16 +0,0 @@
<!--
The PLANNER prompt — go-micro's "architect / founder lens". Editable policy;
the workflow prepends the agent @mention and substitutes __ISSUE__ (this run's
tracking issue) before posting. Keep __ISSUE__ literal.
-->
Act as the architect — the founder lens — for go-micro, running continuously alongside the builders. Hold the whole picture: how the harness, the framework, and the developer UX fit together, what is in flight and what just merged, what to prioritize next, and what is missing or has drifted.
(1) TRACK STATE — scan recently merged PRs and open `codex` PRs/issues to see what shipped and what is being built right now, so the queue reflects reality (drop done items, don't re-queue in-flight work).
(2) ASSESS against the North Star in `.github/loop/NORTH_STAR.md` — lead with its Mission (*make building an agent as easy as building a service, on one runtime*) and re-derive alignment from the CANON: the blog under `internal/website/blog`, the `README`, and the website (read these, don't rely on the North Star alone), then `ROADMAP.md` (Now → Next → Later). Judge every priority against the mission: does it make the services → agents → workflows lifecycle simpler, more cohesive, and more operable? CURRENT GOAL — developer adoption: weight the on-ramp (walkable first-agent tutorial, discoverable examples, docs wayfinding, install friction, debugging, 0→1 and 0→hero) at least as highly as internal hardening; do not let the queue fill entirely with internal depth work. Look at coherence and seams across the core packages (agent, ai, flow, gateway/mcp, gateway/a2a, model, server, store, registry) and the dev inner loop (scaffold → run → chat → inspect → deploy). Flag drift in either direction: work drifting from the mission, or the North Star/website drifting from the lived story in the blog.
(3) MAINTAIN THE QUEUE in `.github/loop/PRIORITIES.md` — a SINGLE ordered list, highest-value first, each item linking a scoped, CI-verifiable issue (#N); roadmap phase is the primary ordering, internal findings (cohesion gaps, DX friction, missing pieces) interleaved by value. For any prioritized gap with no issue, file one: `gh issue create --label codex --label enhancement --title "<scoped task>" --body "<goal, scope, acceptance criteria>"`.
OUTPUT: post a concise assessment as a comment on this issue (#__ISSUE__) — what shipped, what's in flight, the top risks/gaps, and the reasoning behind the ranking. If the ranking actually changed, open ONE PR for `.github/loop/PRIORITIES.md`: `git switch -c codex/planner-__ISSUE__`, `git push -u origin codex/planner-__ISSUE__`, `gh pr create --base master --label codex --title "<title>" --body "<summary, Closes #__ISSUE__>"`, then `gh pr merge --squash --auto --delete-branch`. If the queue is already accurate, just close this issue (`gh issue close __ISSUE__`).
Do NOT make breaking public-API or architectural changes yourself — surface those in the assessment as notes for the human, never as auto-merged changes. Open the PR yourself from the shell with `gh`; do not use the make_pr tool (it is a no-op stub).
-30
View File
@@ -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.
-14
View File
@@ -1,14 +0,0 @@
<!--
The TRIAGE prompt — go-micro's CI-failure feedback path. Editable policy; the
workflow prepends the agent @mention and substitutes __ISSUE__ (this tracking
issue) and __RUNURL__ (the failed run) before posting. Keep both literal.
-->
Triage the failed CI run at __RUNURL__. It may be the linter (Lint), the unit/integration tests (Run Tests), or the provider-conformance harness (Harness (E2E)).
Read the logs and root-cause each distinct failure. DEDUPE against open issues — if a failure matches an existing issue, comment "recurred" there instead of filing a duplicate.
For each genuine, self-contained defect, file a scoped issue (`gh issue create --label codex --label enhancement --title "<scoped fix>" --body "<root cause, where, acceptance criteria>"`) so the increment loop builds it and the next CI/harness run verifies it. A lint or test failure on master is a real regression — file it so it is fixed promptly; do NOT ignore it.
IGNORE only genuine transient flakes — live-model latency, provider outages, rate limits, network timeouts with no code cause (mostly relevant to the harness). Anything needing a breaking or architectural change: file it as `needs-human` and describe it, rather than auto-queuing it as a routine fix.
Close this issue (`gh issue close __ISSUE__`) when triage is done. Open any PR yourself from the shell with `gh`; do not use the make_pr tool.
-116
View File
@@ -1,116 +0,0 @@
name: Harness (E2E)
# Runs the end-to-end harnesses for agents, services, flows, and provider
# conformance. The default job uses deterministic mock LLMs and needs no
# secrets. A second job runs the same harnesses against the live provider set and
# skips providers whose secrets are absent, so scheduled conformance
# remains safe in no-key forks while still failing configured providers that drift.
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
schedule:
- cron: "17 * * * *" # hourly, so real-model conformance keeps pace with the dev/loop velocity
workflow_dispatch:
inputs:
providers:
description: "Comma-separated providers for live conformance (default: all supported)"
required: false
default: "anthropic,openai,gemini,groq,minimax,mistral,together,atlascloud"
harnesses:
description: "Comma-separated harnesses for live conformance"
required: false
default: "agent,universe,agent-flow,plan-delegate,a2a-stream-fallback"
require_configured:
description: "Fail selected live providers that do not have repository secrets"
required: false
type: boolean
default: false
jobs:
harness:
name: Harnesses (mock LLM)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: stable
cache: true
- name: Build
run: go build ./...
- name: 0→1 and 0→hero developer-flow harness
run: make harness
harness-live:
name: Provider harnesses (live LLM conformance)
runs-on: ubuntu-latest
# 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.
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: stable
cache: true
- name: Provider conformance against live models
env:
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 }}
# Atlas Cloud's default chat model was failing the agent/tool-use
# conformance harnesses; run it against a stronger tool-use model.
# Override with an Actions variable ATLASCLOUD_MODEL if the exact
# catalog id differs (Atlas uses org/model ids).
ATLASCLOUD_MODEL: ${{ vars.ATLASCLOUD_MODEL || 'minimaxai/minimax-m3' }}
run: |
PROVIDERS="${{ github.event.inputs.providers || 'anthropic,openai,gemini,groq,minimax,mistral,together,atlascloud' }}"
HARNESSES="${{ github.event.inputs.harnesses || 'agent,universe,agent-flow,plan-delegate,a2a-stream-fallback' }}"
REQUIRE_CONFIGURED="${{ github.event.inputs.require_configured || 'false' }}"
args=(
-providers "$PROVIDERS"
-harnesses "$HARNESSES"
-summary-json provider-conformance-summary.json
-summary-markdown provider-conformance-summary.md
-capabilities-markdown provider-capabilities.md
)
if [ "$REQUIRE_CONFIGURED" = "true" ]; then
args+=( -require-configured )
fi
go run ./internal/harness/provider-conformance "${args[@]}"
- name: Publish provider conformance summary
if: always()
run: |
if [ -f provider-conformance-summary.md ]; then
cat provider-conformance-summary.md >> "$GITHUB_STEP_SUMMARY"
fi
if [ -f provider-capabilities.md ]; then
{
echo
echo "## Registered provider capabilities"
echo
cat provider-capabilities.md
} >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload provider conformance summary
if: always()
uses: actions/upload-artifact@v4
with:
name: provider-conformance
path: |
provider-conformance-summary.json
provider-conformance-summary.md
provider-capabilities.md
if-no-files-found: ignore
-30
View File
@@ -1,30 +0,0 @@
name: Lint
on:
push:
branches:
- "**"
pull_request:
types:
- opened
- reopened
- synchronize
branches:
- "**"
jobs:
golangci:
name: golangci-lint
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: 1.24
check-latest: true
cache: true
- name: golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: v2.5.0
-60
View File
@@ -1,60 +0,0 @@
name: "Loop: Builder"
# 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/builder.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: "29 * * * *"
permissions:
issues: write
concurrency:
group: loop-builder
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # needed to read the prompt file
- name: Dispatch builder
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/builder.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: build increment #$RUN_NUMBER" \
--body "Autonomous builder pass. Direction: .github/loop/NORTH_STAR.md; queue: .github/loop/PRIORITIES.md.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching builder."
# 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"
-60
View File
@@ -1,60 +0,0 @@
name: "Loop: Coherence"
# 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/coherence.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 7 * * *"
permissions:
issues: write
concurrency:
group: loop-coherence
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # needed to read the prompt file
- name: Dispatch coherence
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/coherence.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: coherence review #$RUN_NUMBER" \
--body "Autonomous coherence pass. Direction: .github/loop/NORTH_STAR.md; queue: .github/loop/PRIORITIES.md.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching coherence."
# 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"
-60
View File
@@ -1,60 +0,0 @@
name: "Loop: Planner"
# 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/planner.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: "59 * * * *"
permissions:
issues: write
concurrency:
group: loop-planner
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # needed to read the prompt file
- name: Dispatch planner
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/planner.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: planning review #$RUN_NUMBER" \
--body "Autonomous planner pass. Direction: .github/loop/NORTH_STAR.md; queue: .github/loop/PRIORITIES.md.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching planner."
# 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"
-76
View File
@@ -1,76 +0,0 @@
name: "Loop: Release"
# Generated by `micro loop init`. Cuts the next PATCH tag
# (vMAJOR.MINOR.PATCH+1) when the default branch has new commits
# since the latest such tag, and pushes it with a PAT (CODEX_TRIGGER_TOKEN) so any
# tag-triggered release workflow fires. Minor/major bumps stay with a human.
#
# The tag MUST be pushed with a PAT, not the default GITHUB_TOKEN: a tag pushed
# by GITHUB_TOKEN does not trigger other workflows (Actions blocks that recursion).
on:
workflow_dispatch: {}
schedule:
- cron: "0 23 * * *"
permissions:
contents: read
concurrency:
group: loop-release
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # need full history + all tags
# Do NOT persist the default GITHUB_TOKEN as a git credential: it would
# be sent on the PAT push below and override it, so the tag push would
# authenticate as github-actions[bot] and 403. Letting the PAT in the
# push URL be the only credential is the whole point.
persist-credentials: false
- name: Cut the next patch tag if there are new commits
env:
RELEASE_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN }}
REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_TOKEN" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping."
exit 0
fi
git fetch --tags --force
LATEST=$(git tag --list 'v*.*.*' --sort=-v:refname | head -1)
if [ -z "$LATEST" ]; then
echo "no vMAJOR.MINOR.PATCH tag found — aborting so nothing weird gets tagged."
exit 1
fi
echo "latest tag: $LATEST"
COUNT=$(git rev-list --count "$LATEST"..HEAD)
echo "commits since $LATEST: $COUNT"
if [ "$COUNT" -eq 0 ]; then
echo "no new commits since $LATEST — no release."
exit 0
fi
ver="${LATEST#v}"
major="${ver%%.*}"
rest="${ver#*.}"
minor="${rest%%.*}"
patch="${rest#*.}"
case "$major.$minor.$patch" in
[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "unexpected tag shape: $LATEST" ; exit 1 ;;
esac
NEXT="v${major}.${minor}.$((patch + 1))"
echo "cutting: $NEXT ($COUNT commits since $LATEST)"
git config user.name "loop release bot"
git config user.email "noreply@users.noreply.github.com"
git tag -a "$NEXT" -m "Release $NEXT — automated patch ($COUNT commits since $LATEST)"
git push "https://x-access-token:${RELEASE_TOKEN}@github.com/${REPO}.git" "$NEXT"
echo "Pushed $NEXT."
-60
View File
@@ -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"
-57
View File
@@ -1,57 +0,0 @@
name: "Loop: Triage"
# Generated by `micro loop init`. The feedback path of the evaluator: when a CI
# workflow (Harness (E2E), Lint, Run Tests) fails on a non-PR run, dispatch the agent
# (@codex) with the instruction in .github/loop/prompts/triage.md
# to root-cause the failure and file scoped fix issues back into the queue — so
# failures become fixes with no human in the middle. Gated on CODEX_TRIGGER_TOKEN.
on:
workflow_run:
workflows: ["Harness (E2E)", "Lint", "Run Tests"]
types: [completed]
permissions:
issues: write
concurrency:
group: loop-triage
cancel-in-progress: false
jobs:
triage:
# Only real failures on branch pushes/schedules — not PR-run failures, which
# the PR author already sees.
if: ${{ github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.event != 'pull_request' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # needed to read the prompt file
- name: Dispatch triage
env:
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
HAS_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.event.workflow_run.id }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
WORKFLOW_NAME: ${{ github.event.workflow_run.name }}
run: |
if [ "$HAS_TOKEN" != "true" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping."
exit 0
fi
PROMPT=".github/loop/prompts/triage.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: triage failed run $RUN_ID ($WORKFLOW_NAME)" \
--body "The '$WORKFLOW_NAME' workflow failed on a non-PR run: $RUN_URL")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching triage."
{
echo "@codex"
echo
sed -e '/<!--/,/-->/d' -e "s/__ISSUE__/$ISSUE_NUM/g" -e "s#__RUNURL__#$RUN_URL#g" "$PROMPT"
} > "$RUNNER_TEMP/loop-body.md"
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body-file "$RUNNER_TEMP/loop-body.md"
-51
View File
@@ -1,51 +0,0 @@
name: goreleaser
on:
push:
tags:
- 'v*.*.*'
permissions:
contents: write
id-token: write
packages: write
attestations: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
-
name: Set up Go
uses: actions/setup-go@v5
with:
go-version: stable
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
-
name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
-
name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-75
View File
@@ -1,75 +0,0 @@
name: Run Tests
on:
push:
branches:
- "**"
pull_request:
types:
- opened
- reopened
- synchronize
branches:
- "**"
jobs:
unittests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.24
check-latest: true
cache: true
- name: Get dependencies
run: |
go install github.com/kyoh86/richgo@latest
go get -v -t -d ./...
- name: Run tests
id: tests
run: richgo test -v -race -cover ./...
env:
IN_TRAVIS_CI: yes
RICHGO_FORCE_COLOR: 1
etcd-integration:
name: Etcd Integration Tests
runs-on: ubuntu-latest
permissions:
contents: read
services:
etcd:
image: quay.io/coreos/etcd:v3.5.2
env:
ETCD_LISTEN_CLIENT_URLS: http://0.0.0.0:2379
ETCD_ADVERTISE_CLIENT_URLS: http://0.0.0.0:2379
ports:
- 2379:2379
options: >-
--health-cmd "etcdctl endpoint health"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.24
check-latest: true
cache: true
- name: Get dependencies
run: |
go install github.com/kyoh86/richgo@latest
go get -v -t -d ./...
- name: Wait for etcd
run: |
timeout 30 bash -c 'until curl -s http://localhost:2379/health; do sleep 1; done'
- name: Run etcd integration tests
run: richgo test -v -race ./registry/etcd/...
env:
ETCD_ADDRESS: localhost:2379
IN_TRAVIS_CI: yes
RICHGO_FORCE_COLOR: 1
-51
View File
@@ -1,51 +0,0 @@
# Sample workflow for building and deploying a Jekyll site to GitHub Pages
name: Deploy Jekyll with GitHub Pages dependencies preinstalled
on:
# Runs on pushes targeting the default branch
push:
branches: ["master"]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
# Build job
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Build with Jekyll
uses: actions/jekyll-build-pages@v1
with:
source: ./internal/website
destination: ./_site
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
# Deployment job
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+1 -35
View File
@@ -1,10 +1,6 @@
# Develop tools
/.vscode/
/.idea/
/.trunk
# VS Code workspace files (keep settings for consistency)
/.vscode/*
!/.vscode/settings.json
# Binaries for programs and plugins
*.exe
@@ -33,38 +29,8 @@ _cgo_export.*
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
coverage.html
# vim temp files
*~
*.swp
*.swo
# go work files
go.work
go.work.sum
# Build artifacts
dist/
bin/
# Example binaries (go build in examples/)
examples/**/server/server
examples/**/client/client
examples/mcp/documented/documented
examples/mcp/hello/hello
# IDE-specific files
.DS_Store
/micro
# Built example/harness binaries (go build ./path/... drops these at repo root)
/plan-delegate
/agent-plan-delegate
/micro-mcp-gateway
/agent-ollama
# Local Jekyll / Bundler artifacts
internal/website/.bundle/
internal/website/_site/
internal/website/.jekyll-cache/
-63
View File
@@ -1,63 +0,0 @@
version: "2"
run:
timeout: 5m
tests: true
linters:
# Start from the standard set (errcheck, govet, ineffassign, staticcheck,
# unused) and add a few low-noise, high-value linters on top.
default: standard
enable:
- bodyclose
- misspell
- unconvert
- usestdlibvars
settings:
errcheck:
exclude-functions:
- (*encoding/json.Encoder).Encode
- (net/http.ResponseWriter).Write
- fmt.Fprintf
- fmt.Fprint
- fmt.Fprintln
misspell:
locale: US
staticcheck:
checks:
- all
# Deprecations are tracked separately; some (e.g. gRPC dial options)
# are kept intentionally for compatibility. Migrate them on their own.
- -SA1019
# Initialism/naming convention (Id->ID, Http->HTTP, ...). The remaining
# offenders are exported identifiers (e.g. web.Id, web.DefaultId) whose
# rename is a breaking API change; not worth it in a lint-bootstrap pass.
- -ST1003
exclusions:
# Use golangci-lint's built-in sensible exclusions and skip generated code.
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
# The protobuf code generator is a port of upstream protoc-gen-go and
# keeps its structure; don't flag its unused legacy helpers.
- path: cmd/protoc-gen-micro/generator/
linters:
- unused
# Tests are held to a looser standard.
- path: _test\.go
linters:
- bodyclose
- errcheck
# Demo/harness code: fire-and-forget calls are fine and `_ =` noise hurts
# readability of examples.
- path: (^|/)(examples|internal/harness)/
linters:
- errcheck
formatters:
enable:
- gofmt
-136
View File
@@ -1,136 +0,0 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 2
before:
hooks:
- go mod tidy
builds:
- main: ./cmd/micro
id: micro
binary: micro
env:
- CGO_ENABLED=0
- >-
{{- if eq .Os "darwin" }}
{{- if eq .Arch "amd64"}}CC=o64-clang{{- end }}
{{- if eq .Arch "arm64"}}CC=aarch64-apple-darwin20.2-clang{{- end }}
{{- end }}
{{- if eq .Os "windows" }}
{{- if eq .Arch "amd64" }}CC=x86_64-w64-mingw32-gcc{{- end }}
{{- end }}
goos:
- linux
- windows
- darwin
goarch:
- amd64
- arm
- arm64
goarm:
- 7
ignore:
- goos: windows
goarch: arm
- main: ./cmd/protoc-gen-micro
id: protoc-gen-micro
binary: protoc-gen-micro
env:
- CGO_ENABLED=0
- >-
{{- if eq .Os "darwin" }}
{{- if eq .Arch "amd64"}}CC=o64-clang{{- end }}
{{- if eq .Arch "arm64"}}CC=aarch64-apple-darwin20.2-clang{{- end }}
{{- end }}
{{- if eq .Os "windows" }}
{{- if eq .Arch "amd64" }}CC=x86_64-w64-mingw32-gcc{{- end }}
{{- end }}
goos:
- linux
- windows
- darwin
goarch:
- amd64
- arm
- arm64
goarm:
- 7
ignore:
- goos: windows
goarch: arm
archives:
- id: micro
ids:
- micro
formats: [tar.gz]
name_template: >-
{{ .Binary }}_
{{- .Os }}_
{{- .Arch }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
files:
- none*
format_overrides:
- goos: windows
formats: [zip]
- id: protoc-gen-micro
ids:
- protoc-gen-micro
formats: [tar.gz]
name_template: >-
{{ .Binary }}_
{{- .Os }}_
{{- .Arch }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
files:
- none*
format_overrides:
- goos: windows
formats: [zip]
report_sizes: true
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
dockers_v2:
-
ids:
- micro
- protoc-gen-micro
images:
- "micro/micro"
- "ghcr.io/micro/go-micro"
tags:
- "v{{ .Version }}"
- "{{ if .IsNightly }}nightly{{ end }}"
- "{{ if not .IsNightly }}latest{{ end }}"
labels:
"io.artifacthub.package.readme-url": "https://raw.githubusercontent.com/micro/go-micro/refs/heads/master/README.md"
"io.artifacthub.package.logo-url": "https://www.gravatar.com/avatar/09d1da3ea9ee61753219a19016d6a672?s=120&r=g&d=404"
"org.opencontainers.image.description": "A Go Platform built for Developers"
"org.opencontainers.image.created": "{{.Date}}"
"org.opencontainers.image.title": "{{.ProjectName}}"
"org.opencontainers.image.revision": "{{.FullCommit}}"
"org.opencontainers.image.version": "{{.Version}}"
"org.opencontainers.image.source": "{{.GitURL}}"
"org.opencontainers.image.url": "{{.GitURL}}"
"org.opencontainers.image.licenses": "MIT"
platforms:
- linux/amd64
- linux/arm64
retry:
attempts: 5
delay: 5s
max_delay: 2m
+11
View File
@@ -0,0 +1,11 @@
language: go
go:
- 1.13.x
env:
- GO111MODULE=on
notifications:
slack:
secure: aEvhLbhujaGaKSrOokiG3//PaVHTIrc3fBpoRbCRqfZpyq6WREoapJJhF+tIpWWOwaC9GmChbD6aHo/jMUgwKXVyPSaNjiEL87YzUUpL8B2zslNp1rgfTg/LrzthOx3Q1TYwpaAl3to0fuHUVFX4yMeC2vuThq7WSXgMMxFCtbc=
cache:
directories:
- $GOPATH/pkg/mod
-137
View File
@@ -1,137 +0,0 @@
{
"folders": [
{
"path": "."
}
],
"settings": {
"go.toolsManagement.autoUpdate": true,
"go.useLanguageServer": true,
"go.lintOnSave": "workspace",
"go.lintTool": "golangci-lint",
"go.lintFlags": [
"--fast"
],
"go.formatTool": "goimports",
"go.formatFlags": [],
"go.buildOnSave": "workspace",
"go.testOnSave": false,
"go.coverOnSave": false,
"go.testFlags": ["-v", "-race"],
"go.testTimeout": "60s",
"go.gopath": "",
"go.goroot": "",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
},
"files.exclude": {
"**/.git": true,
"**/.DS_Store": true,
"**/node_modules": true,
"**/*.test": true,
"**/coverage.out": true,
"**/coverage.html": true
},
"files.watcherExclude": {
"**/.git/objects/**": true,
"**/.git/subtree-cache/**": true,
"**/node_modules/**": true,
"**/.vscode/**": true
},
"search.exclude": {
"**/node_modules": true,
"**/bower_components": true,
"**/*.code-search": true,
"**/vendor": true,
"**/.git": true
},
"[go]": {
"editor.tabSize": 4,
"editor.insertSpaces": false,
"editor.formatOnSave": true,
"editor.defaultFormatter": "golang.go"
},
"[go.mod]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "golang.go"
},
"[markdown]": {
"editor.formatOnSave": false,
"editor.wordWrap": "on"
},
"gopls": {
"ui.semanticTokens": true,
"ui.completion.usePlaceholders": true,
"formatting.gofumpt": false,
"analyses": {
"unusedparams": true,
"shadow": true,
"fieldalignment": false
}
}
},
"extensions": {
"recommendations": [
"golang.go",
"editorconfig.editorconfig",
"redhat.vscode-yaml",
"ms-vscode.makefile-tools"
]
},
"tasks": {
"version": "2.0.0",
"tasks": [
{
"label": "Run Tests",
"type": "shell",
"command": "make test",
"group": {
"kind": "test",
"isDefault": true
},
"presentation": {
"reveal": "always",
"panel": "new"
}
},
{
"label": "Run Tests with Coverage",
"type": "shell",
"command": "make test-coverage",
"group": "test"
},
{
"label": "Run Linter",
"type": "shell",
"command": "make lint",
"group": "build"
},
{
"label": "Format Code",
"type": "shell",
"command": "make fmt",
"group": "build"
}
]
},
"launch": {
"version": "0.2.0",
"configurations": [
{
"name": "Debug Current File",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${file}"
},
{
"name": "Debug Test",
"type": "go",
"request": "launch",
"mode": "test",
"program": "${workspaceFolder}"
}
]
}
}
-39
View File
@@ -1,39 +0,0 @@
# Repository agent instructions
These instructions apply to the entire repository.
## Pull requests from Codex tasks
When a Codex task makes repository changes and the requested outcome is a PR:
1. Keep the change focused on the assigned issue or prompt.
2. Run the relevant verification commands and capture their results
(`go build ./...`, `go test ./...`, `golangci-lint run ./...`).
3. Check `git status --short` and review the diff before finishing.
4. Create a uniquely-named branch under the `codex/` prefix (do not work on
`master`, and do not use a generic name like `work`):
```sh
git switch -c codex/<issue-number>-<short-slug>
```
5. Stage the intended files and commit on that branch.
6. Open the pull request yourself with the GitHub CLI, which is installed in the
environment and whose `origin` points at this repository, then enable
auto-merge so GitHub merges it once the required CI checks pass:
```sh
git push -u origin HEAD
gh pr create --base master --label codex \
--title "<concise title>" \
--body "<summary of the change and testing, including 'Closes #<issue>'>"
gh pr merge --squash --auto --delete-branch
```
The branch should start with `codex/` and the PR should carry the `codex`
label. Auto-merge waits for the required status checks (build, tests,
golangci-lint) — never merge a PR manually before CI is green.
Do not just say that a PR was opened, and do **not** rely on the `make_pr` tool:
in this environment `make_pr` only records the title/body and never pushes a
branch or creates a PR. The task is not complete until `gh pr create` has opened
a real pull request and printed its URL.
-193
View File
@@ -1,193 +0,0 @@
# Changelog
All notable changes to Go Micro are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/) and versions
follow [Semantic Versioning](https://semver.org/), matching the git tags and
[GitHub releases](https://github.com/micro/go-micro/releases) (`v6.MINOR.PATCH`).
Patch releases are cut automatically as the loop merges improvements; the
`[Unreleased]` section below is kept current between tags and rolled into the
next version when it ships.
> Earlier `2026.0x` headings are historical calendar-style markers from before
> v6 tagging; they are kept for continuity and not reused.
---
## [Unreleased]
## [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/`)
---
## [6.3.13] - July 2026
### Added
- **`micro loop`** — scaffold an autonomous improvement loop into any repository: GitHub Actions workflows dispatched to an @mention-driven coding agent, across up to five roles — `planner` (ranked queue), `builder` (top item as a single-concern PR, auto-merged on green CI), `triage` (CI failures → fix issues), and opt-in `coherence` (docs/CHANGELOG alignment) and `release` (daily patch tag). Each dispatch role's instruction lives in an editable `.github/loop/prompts/<role>.md` file — the workflow is the mechanism, the prompt is the policy — so a repo customizes behavior without forking the CLI. `micro loop init --roles …` writes it all; `micro loop verify` checks the wiring. This is the loop that maintains go-micro itself, generalized. (`cmd/micro/loop/`)
### Changed
- **x402 payments** — settlement now covers CDP facilitator authentication and conformance edge cases. (`wrapper/x402/`)
### Fixed
- **Plan/delegate harnessing** — side effects and notifications are now idempotent and deterministic across duplicate, alias, order-scoped, and reachability scenarios. (`agent/`, `internal/harness/`)
### Documentation
- **First-agent on-ramp** — quickstart docs now connect the no-secret first-agent transcript, example map, and 0→hero path. (`README.md`, `internal/website/docs/`)
- **Ollama provider docs** — the provider surface, capability matrix, and examples now document local and cloud behavior. (`internal/website/docs/`, `examples/agent-ollama/`)
---
## [6.3.12] - July 2026
### Added
- **Ollama provider** — run agents against open-weight models locally (`/api/chat`, NDJSON streaming) or via Ollama Cloud (OpenAI-compatible `/v1/chat/completions`, SSE), auto-detected from the base URL, with tool calling in both modes. Point any agent at a non-default endpoint with the new `agent.BaseURL` / `micro.AgentBaseURL` option. (`ai/ollama/`, `examples/agent-ollama/`)
- **Retrieval-backed agent memory** — agents can recall relevant prior turns by similarity, not just the recent window, with a summarizer hook that compacts older history so long conversations stay in budget. (`agent/`)
- **Scheduled flows** — a flow can run an agent (or any step) on a cron-style schedule, with the dispatch traced end to end. (`flow/`)
- **Flow verification/grader loop** — a workflow can grade its own step output against a rubric and retry until it passes, plus run-trace analysis to surface where a flow spends its time. (`flow/`)
- **A2A streaming & continuity** — outbound agent streaming flows through the A2A binding (`message/stream`), with `tasks/resubscribe` and `input-required` handoffs for multi-turn interop. (`gateway/a2a/`)
### Changed
- **Agent tool-call resilience** — opt-in retries around agent tool calls, and a fallback that executes tool calls emitted as text by weaker models so they still make progress. (`agent/`)
- **Hardened agent durability** — terminal failure statuses are classified and surfaced, and durable resume-after-restart is covered by tests. (`agent/`)
### Documentation
- **"Your first agent" walkthrough** and a canonical 0-to-hero reference path, lowering the on-ramp from install to a running agent. (`internal/website/docs/`)
- **Discord** linked prominently across the README, website nav/footer, and docs. (`https://discord.gg/G8Gk5j3uXr`)
---
## [6.0.0] - June 2026
The AI-native major release. Breaking changes are listed first; everything
else is additive. See the [v5 → v6 migration guide](internal/website/docs/guides/migration/v5-to-v6.md) — it's a small upgrade.
### Changed (breaking)
- **Module path is now `go-micro.dev/v6`.** Update imports (`go-micro.dev/v5/...``go-micro.dev/v6/...`) and `go install go-micro.dev/v6/cmd/micro@v6`.
- **TLS verification is on by default.** v5 skipped verification unless `MICRO_TLS_SECURE=true`; v6 verifies by default. `MICRO_TLS_SECURE` is removed — set `MICRO_TLS_INSECURE=true` (or call `tls.InsecureConfig()`) for self-signed/dev certs.
- **`micro.NewService(name, opts...)` is the service constructor**, symmetric with `NewAgent`/`NewFlow`. `micro.New(name, opts...)` remains as a deprecated alias; the old name-less `micro.NewService(opts...)` form is removed (pass the name positionally). Generators emit the new form.
- **JWT auth ported in-module.** The external `github.com/micro/plugins/v5/auth/jwt` (pinned to v5) is replaced by `go-micro.dev/v6/auth/jwt/token`, now on the maintained `golang-jwt/jwt/v5`; the deprecated `dgrijalva/jwt-go` dependency is dropped.
### Added
- **A2A protocol — both directions** — `gateway/a2a` exposes registered agents over the open Agent2Agent (A2A) protocol so agents on other frameworks can discover and call them: Agent Cards are generated from registry metadata (the same way the MCP gateway derives tools), and incoming tasks are translated to the agent's existing `Agent.Chat` RPC, with no per-agent code (`micro a2a serve`). The outbound `a2a.Client` calls external A2A agents by URL, wired into `flow.A2A(url)` (a workflow step) and `delegate` to an `http(s)` URL (from inside an agent). An agent can also serve A2A **directly** without a gateway via `AgentA2A(addr)` (`a2a.NewAgentHandler`), handling tasks in-process. The JSON-RPC binding includes `message/send`, `message/stream` (SSE), `tasks/get`, multi-turn continuation by `taskId`/`contextId`, best-effort push notification callbacks, `tasks/resubscribe`, `input-required` handoffs, and card discovery. (`gateway/a2a/`, `cmd/micro/a2a/`)
- **Agents (`micro.NewAgent`)** — an agent is a service with an LLM inside: it discovers its assigned services as tools, runs the model's tool loop, registers a `Chat` RPC endpoint, and is reachable like any service. `Ask` for programmatic use; `micro chat` discovers and routes to agents; `micro agent list`/`describe`. (`agent/`)
- **Plan & delegate** — two built-in agent tools added to every agent: `plan` (an ordered, store-persisted plan surfaced back in the prompt) and `delegate` (hand a self-contained subtask to a registered agent over RPC, otherwise to an ephemeral sub-agent). No harness or graph — they're plain tools. (`agent/builtin.go`, `examples/agent-plan-delegate/`)
- **Agent guardrails** — `MaxSteps` (stop on count), `LoopLimit` (stop repeated no-progress calls; on by default), and `ApproveTool` (human-in-the-loop / policy gate before each action), enforced at the one point every tool call passes through. (`agent/`, guide + blog)
- **Pluggable agent memory & custom tools** — durable store-backed conversation memory by default, swappable via `AgentMemory`; register any function as a tool with `AgentTool`.
- **Workflows (`micro.NewFlow`)** — event-driven orchestration that maps to Anthropic's workflow/agent split: an event triggers a deterministic step (or ordered durable steps), or dispatches to an agent with `FlowAgent`. (`flow/`)
- **Flow loops (`FlowLoop`)** — a flow step that runs a body step repeatedly, carrying state across passes, until a stop condition is met or a hard iteration cap is hit. Stop on a code-defined predicate (`FlowUntil`) or let the model judge it done (`FlowUntilLLM` — the supervised "Ralph" loop); `FlowLoopMax` is the guardrail that guarantees termination, and `FlowOnIteration` reports progress. (`flow/loop.go`, `examples/flow-loop/`, guide)
- **x402 payments** — opt-in per-call payments for tools via the x402 standard, with a pluggable facilitator and a consumer-side client + budget; the MCP gateway can advertise and require payment per tool. (`wrapper/x402/`, guide + blog)
- **Scoped store state** — `store.Scope(s, database, table)` returns a store handle that confines every operation to a database/table without mutating the shared store (unlike `Init(Table(...))`, which is process-global and races between co-located components). Services, agents, and flows now each keep their state in their own table (`service/{name}`, `agent/{name}`, `flow/{name}`); the service path replaces the old `Init(store.Table(name))` global mutation with a scoped handle.
- **Flow discovery & history CLI** — running flows now register in the registry as `type=flow` (and deregister on `Stop`), so they're discoverable like agents: `micro flow list` shows running flows, `micro flow runs <name>` shows a flow's durable run history from the store, and `micro agent history <name>` shows an agent's stored conversation. Live state comes from the registry; durable history from the scoped store.
- **Durable workflows** — a flow can now be an ordered list of steps (a task with stages) that is checkpointed before and after each step, so a run survives a crash and resumes where it stopped without re-running completed steps. State carries a typed payload plus a `Stage` marker; flow-level `Retry` with a per-step override; runs retained for audit unless `DeleteOnSuccess`. Step actions: `Call` (RPC), `LLM` (model turn), `Dispatch` (to an agent), or any `StepFunc`. Durability is a pluggable `Checkpoint` (store-backed by default; implement the interface for Temporal/Restate). Runnable example: `examples/flow-durable/`. Blog: "Durable Workflows" (`internal/website/blog/24.md`).
- **Agent tool-execution wrappers** — `AgentWrapTool` registers middleware around an agent's tool calls, the tool-side analogue of `client.CallWrapper`/`server.HandlerWrapper`. Use it for logging, metrics, retries, or policy; wrappers compose outermost-first and run outside the built-in guardrails. Includes a runnable example with observe + retry wrappers (`examples/agent-wrap-tool/`).
- **Agent platform showcase** — full platform example (Users, Posts, Comments, Mail) mirroring [micro/blog](https://github.com/micro/blog), demonstrating how existing microservices become agent-accessible with zero code changes (`examples/mcp/platform/`).
- **Blog post: "Your Microservices Are Already an AI Platform"** — walkthrough of agent-service interaction patterns using real-world services (`internal/website/blog/7.md`).
- **Circuit breakers for MCP gateway** — per-tool circuit breakers protect downstream services from cascading failures. Configurable max failures, open-state timeout, and half-open probing. Available via `Options.CircuitBreaker` and `--circuit-breaker` CLI flag (`gateway/mcp/circuitbreaker.go`).
- **Helm chart for MCP gateway** — official Helm chart at `deploy/helm/mcp-gateway/` with Deployment, Service, ServiceAccount, HPA, and Ingress templates. Supports Consul/etcd/mDNS registries, JWT auth, rate limiting, audit logging, per-tool scopes, TLS ingress, and auto-scaling.
- **MCP gateway benchmarks** — comprehensive benchmark suite for tool listing, lookup, auth, rate limiting, and JSON serialization (`gateway/mcp/benchmark_test.go`)
- **Workflow example** — cross-service orchestration demo with Inventory, Orders, and Notifications services showing agents chaining multi-step workflows from natural language (`examples/mcp/workflow/`)
- **Docker Compose deployment** — production-like setup with Consul registry, standalone MCP gateway, and Jaeger tracing in one `docker-compose up` (`examples/deployment/`)
---
## [2026.03] - March 2026
### Added
#### Developer Experience
- **`micro new` MCP templates** — `micro new myservice` generates MCP-enabled services with doc comments, `@example` tags, and `WithMCP()` wired in. Use `--no-mcp` to opt out.
- **`micro.NewService("name")` unified API** — single way to create services: `micro.NewService("greeter")` or `micro.NewService("greeter", micro.Address(":8080"))`. Replaces `micro.NewService()` + `service.New()` dual API.
- **`service.Handle()` simplified registration** — register handlers with `service.Handle(new(Greeter))` instead of manual `server.NewHandler` + `server.Handle`.
- **`micro.NewGroup()` modular monoliths** — run multiple services in one binary with shared lifecycle: `micro.NewGroup(users, orders).Run()`.
- **`mcp.WithMCP()` one-liner** — add MCP to any service with a single option: `micro.NewService("name", mcp.WithMCP(":3001"))`.
- **CRUD example** — contact book service with 6 operations, rich agent docs, and validation patterns (`examples/mcp/crud/`).
#### MCP Gateway
- **WebSocket transport** — bidirectional JSON-RPC 2.0 streaming over WebSocket for real-time agent communication (`gateway/mcp/websocket.go`).
- **OpenTelemetry integration** — full span instrumentation across HTTP, stdio, and WebSocket transports with W3C trace context propagation (`gateway/mcp/otel.go`).
- **Standalone gateway binary** — `micro-mcp-gateway` with Docker support for running the MCP gateway independently of services.
- **Per-tool auth scopes** — service-level (`server.WithEndpointScopes()`) and gateway-level (`Options.Scopes`) scope enforcement with bearer token auth.
- **Rate limiting** — per-tool token bucket rate limiting (`Options.RateLimit`).
- **Audit logging** — immutable audit records per tool call with trace ID, account, scopes, duration, and errors (`Options.AuditFunc`).
#### AI Model Package
- **`model.Model` interface** — unified AI provider abstraction with `Generate()` and `Stream()` methods.
- **Anthropic Claude provider** — `model/anthropic` with tool execution and auto-calling.
- **OpenAI GPT provider** — `model/openai` with provider auto-detection from base URL.
#### Agent SDKs
- **LangChain SDK** — `contrib/langchain-go-micro/` Python package with auto-discovery, tool generation, and multi-agent workflow examples.
- **LlamaIndex SDK** — `contrib/go-micro-llamaindex/` Python package with RAG integration examples.
#### Documentation
- **AI-native services guide** — building services for AI agents from scratch
- **MCP security guide** — auth, scopes, and audit logging
- **Tool descriptions guide** — writing doc comments that improve agent performance
- **Agent patterns guide** — architecture patterns for agent integration
- **Error handling guide** — writing agent-friendly error responses with typed errors
- **Troubleshooting guide** — common MCP issues and solutions
- **Migration guide** — add MCP to existing services in 5 minutes
#### CLI
- **`micro mcp serve`** — start MCP server (stdio for Claude Code, HTTP for web agents)
- **`micro mcp list`** — list available tools (human-readable or JSON)
- **`micro mcp test`** — test tools with JSON input
- **`micro mcp docs`** — generate tool documentation
- **`micro mcp export`** — export to LangChain, OpenAPI, or JSON formats
#### Agent Playground
- **Chat-focused UI** — redesigned playground with collapsible tool calls, real-time status, and thinking indicators
- **Provider settings** — configurable OpenAI/Anthropic provider, model, and API key
### Changed
- Service interface moved to `service.Service` with `micro.Service` as a type alias for backward compatibility.
- `service.New()` returns `service.Service` interface (was `*ServiceImpl`).
- `service.NewGroup()` accepts `service.Service` interface (was `*ServiceImpl`).
- `go.mod` template in `micro new` updated to Go 1.22.
### Fixed
- Handler `Handle()` method accepts variadic `server.HandlerOption` for scopes and metadata.
- Store initialization uses service name as table automatically.
- Service `Stop()` properly aggregates errors from lifecycle hooks.
---
## [2026.02] - February 2026
### Added
- **MCP gateway library** — `gateway/mcp/` with HTTP/SSE and stdio transports, service discovery, tool generation, and JSON schema generation from Go types (2,500+ lines).
- **CLI integration** — `micro run --mcp-address` flag to start MCP alongside services.
- **Documentation extraction** — auto-extract tool descriptions from Go doc comments with `@example` tag and struct tag parsing.
- **Blog post** — "Making Microservices AI-Native with MCP"
- **MCP examples** — `examples/mcp/hello/` and `examples/mcp/documented/`
---
## [2026.01] - January 2026
### Added
- **`micro deploy`** — deploy services to any Linux server via SSH + systemd with `micro deploy user@server`.
- **`micro build`** — build Go binaries and Docker images with `micro build --docker`.
- **Blog post** — "Introducing micro deploy"
---
_For earlier changes, see the [git log](https://github.com/micro/go-micro/commits/master)._
-158
View File
@@ -1,158 +0,0 @@
# CLAUDE.md - Go Micro Project Guide
## Project Overview
Go Micro is a framework for distributed systems development in Go. It provides pluggable abstractions for service discovery, RPC, pub/sub, config, auth, storage, and more.
The framework is evolving into an **AI-native platform** where every microservice is automatically accessible to AI agents via the Model Context Protocol (MCP).
## Build & Test
```bash
# Run all tests
make test
# Run tests for a specific package
go test ./gateway/mcp/...
go test ./ai/...
go test ./model/...
# Lint
make lint
# Format
make fmt
# Build CLI
go build -o micro ./cmd/micro
# Run locally with hot reload
micro run
```
## Project Structure
```
go-micro/
├── agent/ # Agent abstraction (intelligent service management)
├── ai/ # AI model providers (Anthropic, OpenAI, Gemini, etc.)
├── auth/ # Authentication (JWT, no-op)
├── broker/ # Message broker (NATS, RabbitMQ)
├── cache/ # Caching (Redis)
├── client/ # RPC client (gRPC)
├── cmd/micro/ # CLI tool (run, deploy, mcp, build, server)
├── codec/ # Message codecs (JSON, Proto)
├── config/ # Dynamic config (env, file, etcd, NATS)
├── errors/ # Error handling
├── events/ # Event system (NATS JetStream)
├── flow/ # Event-driven LLM orchestration
├── gateway/
│ ├── api/ # REST API gateway
│ └── mcp/ # MCP gateway (core AI integration)
│ └── deploy/ # Helm charts for MCP gateway
├── health/ # Health checking
├── logger/ # Logging
├── metadata/ # Context metadata
├── model/ # Typed data models (CRUD, queries, schemas)
├── registry/ # Service discovery (mDNS, Consul, etcd)
├── selector/ # Client-side load balancing
├── server/ # RPC server
├── service/ # Service interface + profiles
├── store/ # Data persistence (Postgres, NATS KV)
├── transport/ # Network transport
├── wrapper/ # Middleware (auth, trace, metrics)
├── examples/ # Working examples
└── internal/ # Non-public: docs, utils, test harness
```
## Key Architectural Decisions
- **Plugin architecture**: All abstractions use Go interfaces. Defaults work out of the box, everything is swappable.
- **Progressive complexity**: Zero-config for development, full control for production.
- **AI-native by default**: Every service is automatically an MCP tool. No extra code needed.
- **In-repo plugins**: Plugins live in the main repo to avoid version compatibility issues.
- **Reflection-based registration**: Handlers are registered via reflection for minimal boilerplate.
## Code Conventions
- Standard Go conventions (gofmt, golint)
- Functional options pattern for configuration (`WithX()` functions)
- Interface-first design: define the interface, then implement
- Tests alongside code (not in separate test directories)
- Commit messages: imperative mood, concise summary line
## Current Focus & Priorities (March 2026)
### Status
- **Q1 2026 (MCP Foundation):** COMPLETE
- **Q2 2026 (Agent DX):** COMPLETE (100%)
- **Q3 2026 (Production):** 50% complete (ahead of schedule)
### Priority 1: Agent Showcase & Examples
Build compelling demos showing agents interacting with go-micro services in realistic scenarios.
### Priority 2: Additional Protocol Support
- gRPC reflection-based MCP
- HTTP/3 support
### Priority 3: Kubernetes & Deployment
- Helm Charts for MCP gateway
- Kubernetes Operator with CRDs
### Recently Completed
- **Agent Plan & Delegate** - Two built-in agent tools: `plan` (ordered plan persisted to store-backed memory, surfaced in the prompt) and `delegate` (hand a subtask to another agent — RPC to a registered agent, else an ephemeral sub-agent with isolated context). Added automatically to every agent; no harness or graph. (`agent/builtin.go`, `examples/agent-plan-delegate/`)
- **`micro new` MCP Templates** - Scaffolds MCP-enabled services with doc comments, `@example` tags, `WithMCP()`. `--no-mcp` to opt out.
- **CRUD Example** - Contact book service with 6 operations, rich agent docs (`examples/mcp/crud/`)
- **Migration Guide** - "Add MCP to Existing Services" guide with 3 approaches
- **Troubleshooting Guide** - Common MCP issues and solutions
- **Error Handling Guide** - Patterns for agent-friendly error responses
- **Documentation Guides** - Six guides: AI-native services, MCP security, tool descriptions, agent patterns, error handling, troubleshooting
- **WithMCP Option** - One-line MCP setup (`gateway/mcp/option.go`)
- **Agent Playground Redesign** - Chat-focused UI with collapsible tool calls
- **Standalone Gateway Binary** - `micro-mcp-gateway` with Docker support
- **WebSocket Transport** - Bidirectional JSON-RPC 2.0 streaming (`gateway/mcp/websocket.go`)
- **OpenTelemetry Integration** - Full span instrumentation with W3C trace context (`gateway/mcp/otel.go`)
- **LlamaIndex SDK** - Python package with RAG examples (`contrib/go-micro-llamaindex/`)
## Key Files
| Purpose | File |
|---------|------|
| MCP Gateway | `gateway/mcp/mcp.go` |
| MCP Docs | `gateway/mcp/DOCUMENTATION.md` |
| AI Interface | `ai/model.go` |
| Model Layer | `model/model.go` |
| CLI Entry | `cmd/micro/main.go` |
| MCP CLI | `cmd/micro/mcp/` |
| Server (run/server) | `cmd/micro/server/server.go` |
| Roadmap | `ROADMAP.md` (full: `internal/website/docs/roadmap.md`) |
| Status | `CHANGELOG.md` |
| Changelog | `CHANGELOG.md` |
| Docs Site | `internal/website/docs/` |
## Roadmap & Status Documents
- **[ROADMAP.md](ROADMAP.md)** - the single, current roadmap (agentic development + DX). Full version at `internal/website/docs/roadmap.md`.
- **[CHANGELOG.md](CHANGELOG.md)** - what shipped and when (the source of truth for status).
- **[internal/docs/IMPLEMENTATION_SUMMARY.md](internal/docs/IMPLEMENTATION_SUMMARY.md)** - Implementation notes
- **[CHANGELOG.md](CHANGELOG.md)** - What changed and when
## Coordination with Codex
Go Micro is maintained by two AI tools — **Claude Code** (you) and **Codex** (its playbook is [CODEX.md](CODEX.md)) — plus the human maintainer, who routes work and owns every merge. To work side by side without collisions:
- **Lanes / branches.** You work on `claude/*` branches; Codex on `codex/*`. Never push to Codex's branch, and never have both agents committing the same branch at once.
- **Base PRs on `master`; don't stack on Codex's in-flight branch.** If that base squash-merges, your commit gets orphaned (this happened — the #3007 fixes had to be re-landed). If the code you need isn't merged yet, wait for it, then branch off `master`. To improve an *open* Codex PR, fix it in place (once Codex is done with the branch, or via an `@codex` comment on the PR) rather than a separate stacked PR.
- **One concern per PR.** Single-purpose PRs; don't bundle (e.g.) a feature with a docs change.
- **Cross-review.** Review Codex's PRs before merge — mechanical fixes you can land yourself (based on `master`), but design/scope/positioning calls go to the human; don't silently rewrite Codex's intent. Codex reviews yours via `@codex review`.
- **Dispatching Codex.** Start a task by commenting `@codex <instruction>` on an issue/PR (that issue/PR is its context). `@codex review` is reserved for review; any other instruction starts a *task*. It's consequential (spends a Codex task slot, pushes commits) and **serial** (one task at a time) — so dispatch one task at a time, only on the human's go-ahead, and never write a literal `@codex` in a comment unless you intend to trigger it (write "Codex" in prose otherwise).
- **CI is the gate.** `go build`, `go test`, `golangci-lint` (blocking), and `make harness` must pass before merge. `internal/harness/` and `examples/` are excluded from errcheck; everything else gets the full set.
- **Backlog = GitHub issues**, each a scoped, self-contained brief with acceptance criteria.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for full guidelines. Key points:
- Open an issue before large changes
- Include tests for new features
- Run `make test` and `make lint` before submitting
- Follow commit message format: `type: description` (e.g., `feat: add WebSocket transport`)
-197
View File
@@ -1,197 +0,0 @@
# Codex Maintainer Playbook
Go Micro has six months of Codex access through OpenAI's Codex for Open Source
program. Use it to increase maintainer throughput without changing the project's
bar for review, tests, or design taste.
## Operating principles
1. **Humans set direction; Codex accelerates execution.** Maintainers choose the
issue, constraints, and acceptance criteria. Codex drafts, investigates, and
verifies.
2. **Small, reviewable changes win.** Prefer focused PRs that can be understood
in one sitting over large speculative rewrites.
3. **Keep the contract green.** Every Codex-assisted change should preserve the
CLI-first getting-started flow, the harnesses, `make test`, and `make lint`.
4. **Document while coding.** If behavior changes, ask Codex to update examples,
guides, and release notes in the same branch.
5. **No blind merges.** Codex output is treated like any contributor output:
reviewed by a maintainer, backed by tests, and checked for public API impact.
## Coordination with Claude Code
Go Micro is maintained by two AI tools — **Codex** (you) and **Claude Code** (its guide is [CLAUDE.md](CLAUDE.md)) — plus the human maintainer, who routes work and owns every merge.
- **Lanes / branches.** You work on `codex/*` branches; Claude Code on `claude/*`. Never push to a branch the other owns, and never have both agents on one branch at once.
- **Base PRs on `master`.** Don't stack a PR on another agent's in-flight branch — if that base squash-merges, your changes can be orphaned. If the code you need isn't merged yet, wait, then branch off `master`. To improve a PR that hasn't merged, push to that PR's branch rather than opening a separate stacked PR — keep the change one mergeable unit.
- **One concern per PR.** Keep each PR single-purpose so a reviewer can read it in one sitting; don't bundle unrelated changes (e.g. a feature plus a docs rebrand).
- **Cross-review before merge.** Claude Code reviews your PRs; you review its with `@codex review`. A fresh pass from the other model catches what the author misses.
- **Dispatch.** Maintainers (or Claude Code) start your tasks with `@codex <instruction>` on the relevant issue/PR — that's your context. `@codex review` is review; any other instruction is a *task*. You run one task at a time: take the current one to a clean, green PR before the next is dispatched.
- **CI is the gate.** `go build`, `go test`, `golangci-lint` (blocking), and `make harness` must pass; never merge red. `internal/harness/` and `examples/` are excluded from errcheck; everything else gets the full set.
- **Backlog = GitHub issues**, each a scoped brief with acceptance criteria.
## Best uses
### 1. PR review and triage
- Summarize a PR: changed surface area, public API impact, tests added or missing.
- Ask for targeted review passes: concurrency, cancellation, security, backwards
compatibility, docs drift, and examples.
- Convert review findings into small patch suggestions or issue comments.
### 2. Issue reproduction
- Turn bug reports into failing tests or runnable reproduction scripts.
- Minimize flakes by isolating registry, broker, store, transport, and AI-provider
dependencies behind deterministic fakes where possible.
- Attach the exact command that reproduces the failure to the issue.
### 3. Release support
- Draft changelog entries from merged commits, grouped by feature, fix, docs, and
compatibility notes.
- Check that `README.md`, `ROADMAP.md`, website docs, examples, and `CHANGELOG.md`
agree before tagging.
- Run dry-run release commands and summarize blockers.
### 4. Docs and examples
- Keep the 0→1 path current: scaffold, run, call, chat, inspect.
- Keep the 0→hero example current: a realistic multi-agent system that exercises
agents, services, flows, MCP, A2A, and observability.
- Add runnable examples for new primitives before adding broad prose.
### 5. Hardening backlog
Use Codex to break roadmap items into small PRs, especially:
- cross-provider conformance scenarios for all supported AI providers;
- timeout, cancellation, retry, and rate-limit behavior;
- durable agent loops on top of the existing checkpoint model;
- streaming across `ai.Stream` and A2A;
- agent run metadata mapped to OpenTelemetry spans.
## Suggested weekly loop
1. Pick one maintenance lane: reviews, bugs, release prep, docs, or hardening.
2. Ask Codex for a branch-sized plan with acceptance criteria and test commands.
3. Have Codex implement the smallest valuable slice.
4. Run the relevant checks locally and in CI.
5. Review the diff as maintainer-owned code, then merge or send it back.
6. Record any recurring prompt, check, or failure mode in this playbook.
## First two weeks
Do not start with a giant feature. Start by making Codex pay rent on maintenance
work that is already on the roadmap and easy to review.
### Day 1: set up the review loop
1. Pick three recent PRs or commits: one feature, one bug fix, and one docs-only
change.
2. Ask Codex to review each using the PR review template below.
3. Compare Codex findings with maintainer judgment. Keep the checks that found
real issues; delete the noisy ones.
4. Turn the final review prompt into a saved project note or issue comment
template.
Success means Codex can produce a useful first-pass review in under ten minutes
without blocking a maintainer on false positives.
### Days 2-3: make bugs reproducible
1. Pick one open bug or flaky area.
2. Ask Codex for a failing test only. Do not allow a fix in the first pass.
3. Review the test for whether it captures the real contract.
4. In a second branch, ask Codex to fix the failure with the smallest patch.
Success means every accepted bug fix starts with a regression test or deterministic
harness case.
### Days 4-5: audit the getting-started contract
Run through the 0→1 path from a clean checkout and ask Codex to patch only the
first broken or confusing step. The target is not new prose; it is a runnable
path that works exactly as documented.
Candidate checks:
```sh
make test
make harness
make lint
go run ./examples/hello-world
go run ./internal/harness/universe
```
### Week 2: choose one roadmap slice
Pick one hardening item and break it into PRs that each land independently. The
best first slice is usually test infrastructure, not product code.
Recommended order:
1. **Provider conformance skeleton**: define one deterministic agent scenario and
gate real-provider runs on credentials.
2. **Cancellation audit**: trace `context.Context` propagation through one package
at a time.
3. **Docs drift audit**: compare `README.md`, `ROADMAP.md`, website docs, and
examples for one shipped feature.
4. **Release checklist dry run**: have Codex build a release-blocker list from the
diff since the previous tag.
## Standing task queue
Keep Codex busy on tasks with clear acceptance criteria:
| Priority | Task | Acceptance criteria |
| --- | --- | --- |
| P0 | PR first-pass review | Summary, risks, required changes, and exact verification commands. |
| P0 | Bug reproduction | A failing test or harness case committed before the fix. |
| P0 | 0→1 docs check | Fresh-checkout commands work as written or a patch fixes the first break. |
| P1 | Cross-provider conformance | One scenario runs against fakes by default and real providers when keys exist. |
| P1 | Cancellation hardening | Tests prove timeout/cancel behavior for the touched package. |
| P1 | Release audit | Changelog, docs, examples, and migration notes agree before tagging. |
| P2 | Example polish | Example is runnable, linked from docs, and covered by a lightweight check. |
## What not to use Codex for yet
- Broad rewrites without a failing test, benchmark, or public design note.
- Public API changes before a maintainer writes the compatibility story.
- Large generated docs that nobody has run.
- Provider-specific behavior that is not checked against the shared `ai.Model`
contract.
## Prompt templates
### PR review
```text
Review this PR for Go Micro. Focus on public API compatibility, cancellation and
context propagation, concurrency safety, tests, and docs drift. Return: summary,
risks, required changes, optional improvements, and exact commands to verify.
```
### Bug reproduction
```text
Reproduce this issue in the smallest Go test or harness change possible. Do not
fix it yet. Explain the failing path and provide the exact command that fails.
```
### Branch implementation
```text
Implement the smallest branch that satisfies this issue. Keep the API compatible
unless explicitly required, update docs/examples when behavior changes, and run
`make test`, `make harness`, and `make lint` or explain any environment blocker.
```
### Release audit
```text
Audit this release branch. Compare CHANGELOG, README, ROADMAP, website docs, and
examples against the diff since the last tag. List inconsistencies, missing
migration notes, and checks to run before tagging.
```
-230
View File
@@ -1,230 +0,0 @@
# Contributing to Go Micro
Thank you for your interest in contributing to Go Micro! This document provides guidelines and instructions for contributing.
## Code of Conduct
Be respectful, inclusive, and collaborative. We're all here to build great software together.
## How Go Micro is built
Go Micro is developed by an **autonomous improvement loop** — a planner, a
generator, and a separate evaluator, running as scheduled GitHub Actions with a
human setting direction. It's the framework's own thesis (an agent operating a
system) pointed at itself: an agent harness, built by agents. The full process —
the planner → generator → evaluator pipeline, the correctness-only merge gate, and
the guardrails — is documented in
[`internal/docs/CONTINUOUS_IMPROVEMENT.md`](internal/docs/CONTINUOUS_IMPROVEMENT.md).
Human contributions follow the same gate: green CI, one concern per PR.
## Getting Started
1. Fork the repository
2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/go-micro.git`
3. Add upstream remote: `git remote add upstream https://github.com/micro/go-micro.git`
4. Create a feature branch: `git checkout -b feature/my-feature`
## Development Setup
```bash
# Install dependencies
go mod download
# Install development tools
make install-tools
# Run tests
make test
# Run tests with race detector and coverage
make test-coverage
# Run linter
make lint
# Format code
make fmt
```
See `make help` for all available commands.
## Making Changes
### Code Guidelines
- Follow standard Go conventions (use `gofmt`, `golint`)
- Write clear, descriptive commit messages
- Add tests for new functionality
- Update documentation for API changes
- Keep PRs focused - one feature/fix per PR
### Commit Messages
Use conventional commits format:
```
type(scope): subject
body
footer
```
Types:
- `feat`: New feature
- `fix`: Bug fix
- `docs`: Documentation changes
- `test`: Test additions/changes
- `refactor`: Code refactoring
- `perf`: Performance improvements
- `chore`: Maintenance tasks
Examples:
```
feat(registry): add kubernetes registry plugin
fix(broker): resolve nats connection leak
docs(examples): add streaming example
```
### Testing
- Write unit tests for all new code
- Ensure existing tests pass
- Add integration tests for plugin implementations
- Test with multiple Go versions (1.20+)
```bash
# Run specific package tests
go test ./registry/...
# Run with verbose output
go test -v ./...
# Run specific test
go test -run TestMyFunction ./pkg/...
# Optional: Use richgo for colored output
go install github.com/kyoh86/richgo@latest
richgo test -v ./...
```
### Documentation
- Update relevant markdown files in `internal/website/docs/`
- Add examples to `internal/website/docs/examples/` for new features
- Update README.md for major features
- Add godoc comments for exported functions/types
## Pull Request Process
1. **Update your branch**
```bash
git fetch upstream
git rebase upstream/master
```
2. **Run tests and linting**
```bash
go test ./...
golangci-lint run
```
3. **Push to your fork**
```bash
git push origin feature/my-feature
```
4. **Create Pull Request**
- Use a descriptive title
- Reference any related issues
- Describe what changed and why
- Add screenshots for UI changes
- Mark as draft if work in progress
5. **PR Review**
- Respond to feedback promptly
- Make requested changes
- Re-request review after updates
### PR Checklist
- [ ] Tests pass locally
- [ ] Code follows Go conventions
- [ ] Documentation updated
- [ ] Commit messages are clear
- [ ] Branch is up to date with master
- [ ] No merge conflicts
## Adding Plugins
New plugins should:
1. Live in the appropriate interface directory (e.g., `registry/myplugin/`)
2. Implement the interface completely
3. Include comprehensive tests
4. Provide usage examples
5. Document configuration options (env vars, options)
6. Add to plugin documentation
Example structure:
```
registry/myplugin/
├── myplugin.go # Main implementation
├── myplugin_test.go # Tests
├── options.go # Plugin-specific options
└── README.md # Usage and configuration
```
## Reporting Issues
Before creating an issue:
1. Search existing issues
2. Check documentation
3. Try the latest version
When reporting bugs:
- Use the bug report template
- Include minimal reproduction code
- Specify versions (Go, Go Micro, plugins)
- Provide relevant logs
## Documentation Contributions
Documentation improvements are always welcome!
- Fix typos and grammar
- Improve clarity
- Add missing examples
- Update outdated information
Documentation lives in `internal/website/docs/`. Preview locally with Jekyll:
```bash
cd internal/website
bundle install
bundle exec jekyll serve --livereload
```
## Community
- GitHub Issues: Bug reports and feature requests
- GitHub Discussions: Questions, ideas, and community chat
- Sponsorship: [GitHub Sponsors](https://github.com/sponsors/micro)
## Release Process
Maintainers handle releases:
1. Update CHANGELOG.md
2. Tag release: `git tag -a v5.x.x -m "Release v5.x.x"`
3. Push tag: `git push origin v5.x.x`
4. GitHub Actions creates release
## Questions?
- Check [documentation](internal/website/docs/)
- Browse [examples](internal/website/docs/examples/)
- Open a [question issue](.github/ISSUE_TEMPLATE/question.md)
Thank you for contributing to Go Micro! 🎉
-26
View File
@@ -1,26 +0,0 @@
FROM alpine:latest
ARG TARGETPLATFORM
ENV USER=micro
ENV GROUPNAME=$USER
ARG UID=1001
ARG GID=1001
RUN addgroup --gid "$GID" "$GROUPNAME" \
&& adduser \
--disabled-password \
--gecos "" \
--home "/micro" \
--ingroup "$GROUPNAME" \
--no-create-home \
--uid "$UID" "$USER"
ENV PATH=/usr/local/go/bin:$PATH
RUN apk --no-cache add git make curl
COPY --from=golang:1.26.0-alpine /usr/local/go /usr/local/go
COPY $TARGETPLATFORM/micro /usr/local/go/bin/
COPY $TARGETPLATFORM/protoc-gen-micro /usr/local/go/bin/
WORKDIR /micro
EXPOSE 8080
ENTRYPOINT ["/usr/local/go/bin/micro"]
CMD ["server"]
+2 -12
View File
@@ -1,3 +1,4 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
@@ -175,18 +176,7 @@
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Copyright 2015 Asim Aslam.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
-113
View File
@@ -1,113 +0,0 @@
NAME = micro
GIT_COMMIT = $(shell git rev-parse --short HEAD)
GIT_TAG = $(shell git describe --abbrev=0 --tags --always --match "v*")
GIT_IMPORT = go-micro.dev/v5/cmd/micro
BUILD_DATE = $(shell date +%s)
LDFLAGS = -X $(GIT_IMPORT).BuildDate=$(BUILD_DATE) -X $(GIT_IMPORT).GitCommit=$(GIT_COMMIT) -X $(GIT_IMPORT).GitTag=$(GIT_TAG)
# GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser-cross:v1.25.7
GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser:latest
.PHONY: test test-race test-coverage harness install-smoke provider-conformance-mock provider-conformance lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
# Default target
help:
@echo "Go Micro Development Tasks"
@echo ""
@echo " make test - Run tests"
@echo " make test-race - Run tests with race detector"
@echo " make test-coverage - Run tests with coverage"
@echo " make lint - Run linter"
@echo " make harness - Run deterministic getting-started and end-to-end harnesses"
@echo " make install-smoke - Verify the local install.sh and first-run CLI smoke path"
@echo " make provider-conformance-mock - Run cross-provider harness with deterministic mock provider"
@echo " make provider-conformance - Run harnesses against configured live providers"
@echo " make fmt - Format code"
@echo " make install-tools - Install development tools"
@echo " make proto - Generate protobuf code"
@echo " make clean - Clean build artifacts"
$(NAME):
CGO_ENABLED=0 go build -ldflags "-s -w ${LDFLAGS}" -o $(NAME) cmd/micro/main.go
# Run tests
test:
go test -v ./...
# Run tests with race detector
test-race:
go test -v -race ./...
# Run tests with coverage
test-coverage:
go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
go tool cover -html=coverage.out -o coverage.html
@echo "Coverage report: coverage.html"
# Run the documented getting-started contracts plus the deterministic
# services → agents → workflows harnesses (mock LLM — no API key).
# This mirrors the default CI path so local dogfooding catches scaffold,
# run/chat/inspect, and 0→hero regressions before a PR is opened.
harness:
$(MAKE) install-smoke
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
./internal/harness/zero-to-hero-ci/run.sh
go run ./internal/harness/agent-flow
$(MAKE) provider-conformance-mock
# Verify the documented install script and first-run CLI command boundaries without
# provider keys or network access.
install-smoke:
./internal/harness/install-smoke/run.sh
# Run the shared provider conformance contract with the deterministic mock
# provider. This is the no-secret path used by CI and local dogfooding to keep
# provider-facing agent/tool semantics covered on every machine.
provider-conformance-mock:
go run ./internal/harness/provider-conformance -providers mock
# Run the same harnesses against every configured live provider. Providers
# without API keys are skipped; configured providers must pass.
provider-conformance:
go run ./internal/harness/provider-conformance
# Run linter
lint:
golangci-lint run
# Format code
fmt:
gofmt -s -w .
goimports -w .
# Install development tools
install-tools:
@echo "Installing development tools..."
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
go install golang.org/x/tools/cmd/goimports@latest
go install github.com/kyoh86/richgo@latest
go install go-micro.dev/v5/cmd/protoc-gen-micro@latest
@echo "Tools installed successfully"
# Generate protobuf code
proto:
@echo "Generating protobuf code..."
find . -name "*.proto" -not -path "./vendor/*" -exec protoc --proto_path=. --micro_out=. --go_out=. {} \;
# Clean build artifacts
clean:
rm -f coverage.out coverage.html
find . -name "*.test" -type f -delete
go clean -cache -testcache
# Try binary release
gorelease-dry-run:
docker run \
--rm \
-e CGO_ENABLED=0 \
-v $(CURDIR):/$(NAME) \
-v /var/run/docker.sock:/var/run/docker.sock \
-w /$(NAME) \
$(GORELEASER_DOCKER_IMAGE) \
--clean --verbose --skip=publish,validate --snapshot
+31 -450
View File
@@ -1,468 +1,49 @@
# Go Micro [![Go.Dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/go-micro.dev/v6?tab=doc) [![Go Report Card](https://goreportcard.com/badge/github.com/go-micro/go-micro)](https://goreportcard.com/report/github.com/go-micro/go-micro) [![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/G8Gk5j3uXr)
# Go Micro [![License](https://img.shields.io/:license-apache-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![GoDoc](https://godoc.org/github.com/micro/go-micro?status.svg)](https://godoc.org/github.com/micro/go-micro) [![Travis CI](https://api.travis-ci.org/micro/go-micro.svg?branch=master)](https://travis-ci.org/micro/go-micro) [![Go Report Card](https://goreportcard.com/badge/micro/go-micro)](https://goreportcard.com/report/github.com/micro/go-micro)
Go Micro is an **agent harness** and service framework for Go.
Go Micro is a framework for microservice development.
**Community:** questions, ideas, or just want to build alongside us? [Join the Discord](https://discord.gg/G8Gk5j3uXr).
## Overview
A harness is the runtime around an agent: the tools it can call, the memory it keeps, the guardrails that bound it, the workflows that trigger it, the services it depends on, and the protocols other agents use to reach it.
Go Micro provides the core requirements for distributed systems development including RPC and Event driven communication.
The **micro** philosophy is sane defaults with a pluggable architecture. We provide defaults to get you started quickly
but everything can be easily swapped out.
Go Micro gives you the harness as Go code. Build an agent and it gets a model, memory, tools, planning, delegation, guardrails, and service discovery; it is reachable over [MCP](https://modelcontextprotocol.io/) and [A2A](https://a2a-protocol.org). Write services and every endpoint becomes an AI-callable tool. Orchestrate the deterministic parts with durable flows. Agents, services, and flows share one runtime because an agent is a distributed system, and building one is building a service.
<img src="https://micro.mu/docs/images/go-micro.svg" />
## Sponsors
Plugins are available at [github.com/micro/go-plugins](https://github.com/micro/go-plugins).
<a href="https://go-micro.dev/blog/3"><img src="https://upload.wikimedia.org/wikipedia/commons/7/78/Anthropic_logo.svg" height="26" /></a>
&nbsp;&nbsp;
<a href="https://go-micro.dev/blog/29"><img src="https://upload.wikimedia.org/wikipedia/commons/4/4d/OpenAI_Logo.svg" height="26" /></a>
&nbsp;&nbsp;
<a href="https://go-micro.dev/blog/8"><img src="https://www.atlascloud.ai/logo.svg" height="26" /></a>
**Want to support Go Micro and see your logo here?** [Become a sponsor](https://discord.gg/G8Gk5j3uXr) — reach out on Discord.
## Commercial Support
Running Go Micro in production, or building on it and want help? Paid **support, consulting, training, and retainers** are available directly from the maintainer — and they're what keep the project maintained. See [**Support**](SUPPORT.md) for the tiers, or [open a request](https://github.com/micro/go-micro/issues/new?template=commercial_support.md).
## Contents
- [Quick Start](#quick-start)
- [First agent on-ramp](#first-agent-on-ramp)
- [Why an Agent Harness](#why-an-agent-harness)
- [Writing Services](#writing-services)
- [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)
- [Multi-Service Projects](#multi-service-projects)
- [Data Model](#data-model)
- [AI Providers](#ai-providers)
- [Examples](#examples)
- [Commercial Support](#commercial-support)
- [Docs](#docs)
## Quick Start
Install the CLI:
```bash
# Binary (no Go required)
curl -fsSL https://go-micro.dev/install.sh | sh
# Or with Go
go install go-micro.dev/v6/cmd/micro@latest
```
### Fastest start — no API key
Scaffold a service, run it, call it:
```bash
micro new helloworld
cd helloworld
micro run
```
Then in another terminal:
```bash
curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call \
-H 'Content-Type: application/json' -d '{"name":"World"}'
```
This install → scaffold → run → call path is covered by no-secret CI harnesses. To
verify just the local installer and first-run CLI boundaries without network
access or provider keys, use:
```bash
make install-smoke
```
To run the broader local contract (including the [0→hero services → agents → workflows path](internal/website/docs/guides/zero-to-hero.md),
chat/inspect CLI boundaries, and deploy dry-run), use:
```bash
make harness
```
### First agent on-ramp
After install and the first `micro new`/`micro run` smoke check, take the
walkable agent path in this order:
1. [Smallest first-agent example](examples/first-agent/) — run one service-backed agent with a mock model and no provider key.
2. [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.
3. [Your First Agent](internal/website/docs/guides/your-first-agent.md) — build a
service-backed agent and talk to it with `micro chat`.
4. [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.
5. [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.
### 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:
```bash
export ANTHROPIC_API_KEY=sk-ant-... # or OPENAI_API_KEY, GEMINI_API_KEY, ...
micro run --prompt "a task management system with categories" --provider anthropic
```
The AI designs the architecture, you review it, then it generates handlers with real business logic, compiles them, and starts them:
```
Services:
● task — Task management with status tracking
● project — Project organization
Generate? [Y/n]
Micro
Services:
● task
● project
Agents:
◆ agent
```
Then talk to your services from the console:
```
> Create a project called Launch, then add three tasks to it
→ project_Project_Create({"name":"Launch"})
← {"record":{"id":"p1..."},"success":true}
→ task_Task_Create({"title":"Design specs","project_id":"p1..."})
→ task_Task_Create({"title":"Write code","project_id":"p1..."})
→ task_Task_Create({"title":"Ship it","project_id":"p1..."})
Created project Launch and added three tasks to it.
```
When you need a capability that doesn't exist, the agent generates a new service mid-conversation:
```
> I need to track shipping. Create a shipment for order 123 to London.
⚡ generating shipping service...
✓ shipping
→ shipping_Shipping_Create({"order_id":"123","destination":"London"})
← {"record":{"id":"xyz...","status":"pending"}}
Created shipment for order 123 going to London.
```
Edit the generated code by hand at any time — re-running preserves your changes. [Read more](https://go-micro.dev/blog/13).
## Why an Agent Harness
The first wave of agent frameworks helped developers put a model in a loop. The next problem is operating that loop: connecting it to real tools, scoping what it can touch, preserving state, routing work to specialists, recovering from failures, observing what happened, and letting other agents call it. That is harness work.
Go Micro's answer is to make the harness the same thing you already deploy:
- **Tools are services** — endpoint metadata becomes tool schema; RPC executes the call.
- **Agents are services** — they register, discover, load-balance, and expose `Agent.Chat`.
- **Workflows are durable code paths** — use flows when the path is known; dispatch to agents when it is not.
- **Safety lives at execution** — `MaxSteps`, `LoopLimit`, `ApproveTool`, and tool wrappers run where actions happen.
- **Interop is built in** — MCP for tools, A2A for agents, x402 for paid tools.
Use Go Micro when the agent has to operate a system, not just answer a prompt.
## Writing Services
Under the hood, a service is a struct with methods. Doc comments and `@example` tags become tool descriptions for AI agents automatically.
```go
package main
import (
"context"
"go-micro.dev/v6"
)
type Request struct {
Name string `json:"name"`
}
type Response struct {
Message string `json:"message"`
}
type Say struct{}
// Hello greets a person by name.
// @example {"name": "Alice"}
func (h *Say) Hello(ctx context.Context, req *Request, rsp *Response) error {
rsp.Message = "Hello " + req.Name
return nil
}
func main() {
service := micro.NewService("greeter")
service.Handle(new(Say))
service.Run()
}
```
Run it and everything is accessible — REST, gRPC, MCP, agent playground:
```bash
micro run
# Dashboard: http://localhost:8080
# API: http://localhost:8080/api/{service}/{method}
# Agent: http://localhost:8080/agent
# MCP Tools: http://localhost:8080/mcp/tools
```
You can also scaffold a service from a template:
```bash
micro new helloworld
micro new contacts --template crud
```
## Building Agents
An Agent is a service with an LLM inside it. It has a proto-defined `Agent.Chat` RPC endpoint, registers in the registry, and is callable like any service:
```go
agent := micro.NewAgent("task-mgr",
micro.AgentServices("task", "project"),
micro.AgentPrompt("You manage tasks and projects. You understand deadlines and priorities."),
micro.AgentProvider("anthropic"),
)
agent.Run()
```
The agent discovers its services from the registry, scopes its tools to their endpoints, and maintains conversation memory in the store. It registers itself so `micro chat` and other agents can find it.
```go
// Programmatic interaction
resp, _ := agent.Ask(ctx, "What tasks are overdue?")
fmt.Println(resp.Reply)
```
Multiple agents coordinate via RPC — each is a service with an `Agent.Chat` endpoint. `micro chat` routes to the right one.
```bash
micro agent list # list registered agents
micro call task-mgr Agent.Chat '{"message": "What tasks are overdue?"}'
```
### Plan & Delegate
Every agent gets two built-in harness capabilities, exposed as tools — no extra setup or separate graph runtime:
- **`plan`** — for multi-step work, the agent records an ordered plan in its store-backed memory and stays oriented across turns.
- **`delegate`** — the agent hands a self-contained subtask to another agent. If a registered agent already owns the relevant services, the hand-off goes over RPC to that agent; otherwise a focused, short-lived sub-agent is created for the subtask with its own isolated context.
This keeps intelligence distributed: an agent doesn't need to know *how* to do everything, only *who* does. See [examples/agent-plan-delegate](examples/agent-plan-delegate/).
```go
// A sub-agent is just an agent — created with New, talked to with Ask.
// delegate-first: reuse a registered agent, or spin up a focused one.
resp, _ := agent.Ask(ctx, "Plan the launch, create the tasks, and have comms notify the owner.")
```
### Batteries included, pluggable
Just as a service composes pluggable abstractions (registry, broker, store), an agent composes a **model**, **memory**, and **tools** — sane defaults out of the box, each swappable.
```go
agent := micro.NewAgent("assistant",
micro.AgentProvider("anthropic"), // model — swap the provider
micro.AgentCompactMemory(40, 12), // memory — durable, summarized, recallable
micro.AgentTool("weather", "Get the weather for a city",
map[string]any{"city": map[string]any{"type": "string"}},
func(ctx context.Context, in map[string]any) (string, error) {
return getWeather(in["city"].(string)) // tools beyond your services — any function
}),
micro.AgentMaxSteps(8), // guardrails
)
```
**Memory** is durable and store-backed by default (Postgres, NATS KV, or file), so an agent picks up where it left off after a restart — or supply your own with `AgentMemory`. Long-running agents can opt into `AgentCompactMemory(maxMessages, keepRecent)`: older turns are collapsed into a deterministic summary, recent turns stay verbatim, and relevant archived turns are recalled on future asks without replaying the whole conversation. **Tools** are your services automatically, plus any function you register with `AgentTool`.
### Paid tools (x402)
Every endpoint is an AI-callable tool — and it can be a *paid* tool. Go Micro supports [x402](https://x402.org), the HTTP 402 payment standard for agents, so a tool can require a stablecoin payment and an agent can settle it autonomously. It's opt-in and carries no crypto in the framework: verification is delegated to a pluggable facilitator (Coinbase, Alchemy, self-hosted), so Base and Solana are just different facilitators.
```bash
# Charge for tool calls at the MCP gateway (off unless you set a pay-to address)
micro mcp serve --x402_pay_to 0xYourAddress --x402_network solana --x402_amount 10000
# Per-tool amounts via a config file
micro mcp serve --x402_config x402.json
```
See the [Payments (x402) guide](internal/website/docs/guides/x402-payments.md).
### Reachable by other agents (A2A)
Within a Go Micro system, agents reach each other over RPC. To make them reachable by agents on *other* frameworks, Go Micro speaks the [Agent2Agent (A2A) protocol](https://a2a-protocol.org). The A2A gateway discovers your agents from the registry, generates an Agent Card for each from its metadata — the same way the MCP gateway derives tools from service endpoints — and translates incoming A2A tasks to the agent's `Agent.Chat` RPC. No per-agent code: register an agent and it's reachable over A2A.
```bash
micro a2a serve --address :4000 # gateway: expose every registered agent over A2A
micro a2a list # agents and their Agent Card URLs
```
Or skip the gateway entirely — an agent can serve its own A2A endpoint directly, handling tasks in-process:
```go
micro.NewAgent("task-mgr", micro.AgentServices("task"), micro.AgentA2A(":4000"))
```
It works both ways. To call an agent on another framework, an `a2a.Client` is wired into the two places that hand off work: `flow.A2A(url)` as a workflow step (the cross-framework `Dispatch`), and `delegate` to an `http(s)` URL from inside an agent.
MCP exposes your services as tools; A2A exposes your agents as agents. See the [A2A guide](internal/website/docs/guides/a2a-protocol.md).
Follow us on [Twitter](https://twitter.com/microhq) or join the [Slack](http://slack.micro.mu/) community.
## Features
### AI
Go Micro abstracts away the details of distributed systems. Here are the main features.
| Feature | Details |
|---------|---------|
| Agents | `micro.NewAgent()` — intelligent layer that manages services |
| Plan & delegate | Built-in agent tools — plan multi-step work, delegate subtasks to other agents |
| Pluggable memory | Durable store-backed conversation memory by default; swap with `AgentMemory` |
| Custom tools | `AgentTool` — give an agent any function as a tool, beyond its services |
| Guardrails | `MaxSteps` (stop on count), `LoopLimit` (stop repeated no-progress calls), `ApproveTool` (human-in-the-loop) |
| Tool middleware | `AgentWrapTool` — wrap tool execution for logging, metrics, or retries (like client/server wrappers) |
| Workflows | `micro.NewFlow()` — event-driven; one step, ordered durable steps, or triggers an agent |
| Durable execution | Checkpointed flow steps survive a crash and resume where they stopped; store-backed by default, pluggable backend |
| MCP gateway | Every endpoint is an AI tool automatically |
| A2A gateway | Every agent is reachable over the Agent2Agent protocol; cards generated from the registry (`micro a2a`) |
| Payments (x402) | Opt-in per-call payments for tools via the x402 standard; pluggable facilitator (Base, Solana, …) |
| 9 LLM providers | Anthropic, OpenAI, Gemini, Groq, Mistral, Together, Atlas Cloud, MiniMax, Ollama (local + cloud) |
| Interactive console | `micro run` includes a chat console for talking to services |
| Service generation | `micro run --prompt` — describe a system, get running services |
- **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. You can optionally set gossip using the SWIM protocol for p2p networks or consul for a
resilient cloud-native setup.
### Framework
- **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.
| Feature | Details |
|---------|---------|
| Service registry | mDNS (default), Consul, etcd |
| RPC client/server | gRPC transport, load balancing, streaming |
| Pub/sub events | NATS, RabbitMQ, HTTP broker |
| Key-value store | File (bbolt), Postgres, NATS KV |
| Typed model layer | CRUD + queries, SQLite/Postgres backends |
| Everything swappable | All abstractions are Go interfaces |
- **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.
### Developer experience & deployment
- **Request/Response** - 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. The default
transport is http/1.1 or http2 when tls is enabled.
| Feature | Details |
|---------|---------|
| Hot reload | `micro run` watches files, rebuilds on change |
| Templates | `micro new --template crud/pubsub/api` |
| One-command deploy | `micro deploy user@server` — SSH + systemd, no Docker |
- **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 is point-to-point http/1.1 or http2 when tls
is enabled.
## CLI
- **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. Find plugins in
[github.com/micro/go-plugins](https://github.com/micro/go-plugins).
| Command | Purpose |
|---------|---------|
| `micro run --prompt "..."` | Generate services + agent, start with interactive console |
| `micro run` | Dev mode: hot reload, gateway, interactive console |
| `micro run -d` | Detached mode (no console) |
| `micro chat` | Standalone chat (when not using micro run) |
| `micro agent list` | List registered agents |
| `micro new myservice` | Scaffold a service |
| `micro call service endpoint '{}'` | Call a service or agent from the CLI |
| `micro build` | Compile production binaries |
| `micro deploy user@server` | Deploy via SSH + systemd |
## Getting Started
## Multi-Service Projects
See the [docs](https://micro.mu/docs/go-micro.html) for detailed information on the architecture, installation and use of go-micro.
Run multiple services together:
```go
users := micro.NewService("users", micro.Address(":9001"))
orders := micro.NewService("orders", micro.Address(":9002"))
users.Handle(new(Users))
orders.Handle(new(Orders))
g := micro.NewGroup(users, orders)
g.Run()
```
Or use a `micro.mu` config file:
```
service users
path ./users
service orders
path ./orders
depends users
```
## Data Model
Typed persistence with CRUD and queries:
```go
type User struct {
ID string `json:"id" model:"key"`
Name string `json:"name"`
Email string `json:"email" model:"index"`
}
db := service.Model()
db.Register(&User{})
db.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@example.com"})
var results []*User
db.List(ctx, &results, model.Where("email", "alice@example.com"))
```
Backends: memory (default), SQLite, Postgres.
## AI Providers
Swap providers with a single import — same interface everywhere:
| Provider | Default Model |
|----------|---------------|
| Anthropic | `claude-sonnet-4-20250514` |
| OpenAI | `gpt-4o` |
| Google Gemini | `gemini-2.5-flash` |
| Groq | `llama-3.3-70b-versatile` |
| Mistral | `mistral-large-latest` |
| Together AI | `meta-llama/Llama-3.3-70B-Instruct-Turbo` |
| Atlas Cloud | `deepseek-ai/DeepSeek-V3-0324` |
| MiniMax | `MiniMax-M3` |
| Ollama | `llama3.2` (local) |
```go
m := ai.New("anthropic", ai.WithAPIKey(key))
resp, _ := m.Generate(ctx, &ai.Request{Prompt: "hello"})
```
## Examples
- [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
- [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
See [all examples](examples/README.md).
## Docs
- [Getting Started](internal/website/docs/getting-started.md)
- [AI Integration](internal/website/docs/ai-integration.md)
- [Your First Agent](internal/website/docs/guides/your-first-agent.md)
- [0→hero Reference](internal/website/docs/guides/zero-to-hero.md)
- [Agents and Workflows](internal/website/docs/guides/agents-and-workflows.md)
- [Agent Design](internal/docs/AGENT_DESIGN.md)
- [Plan & Delegate](internal/website/docs/guides/plan-delegate.md)
- [Agent Guardrails](internal/website/docs/guides/agent-guardrails.md)
- [Payments (x402)](internal/website/docs/guides/x402-payments.md)
- [MCP & AI Agents](internal/website/docs/mcp.md)
- [Data Model](internal/website/docs/model.md)
- [Deployment](internal/website/docs/deployment.md)
- [Plugins](internal/website/docs/plugins.md)
Package reference: https://pkg.go.dev/go-micro.dev/v6
+36
View File
@@ -0,0 +1,36 @@
# Go Micro [![License](https://img.shields.io/:license-apache-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![GoDoc](https://godoc.org/github.com/micro/go-micro?status.svg)](https://godoc.org/github.com/micro/go-micro) [![Travis CI](https://api.travis-ci.org/micro/go-micro.svg?branch=master)](https://travis-ci.org/micro/go-micro) [![Go Report Card](https://goreportcard.com/badge/micro/go-micro)](https://goreportcard.com/report/github.com/micro/go-micro)
Go Micro是基于Golang的微服务开发框架。
## 概览
Go Micro提供分布式系统开发的核心库,包含RPC与事件驱动的通信机制。
**micro**的设计哲学是可插拔的架构理念,她提供可快速构建系统的组件,并且可以根据自身的需求剥离默认实现并自行定制。
<img src="https://micro.mu/docs/images/go-micro.svg" />
所有插件可在仓库[github.com/micro/go-plugins](https://github.com/micro/go-plugins)中找到。
可以订阅我们的[Twitter](https://twitter.com/microhq)或者加入[Slack](http://slack.micro.mu/)论坛。
## 特性
Go Micro把分布式系统的各种细节抽象出来。下面是它的主要特性。
- **服务发现(Service Discovery** - 自动服务注册与名称解析。服务发现是微服务开发中的核心。当服务A要与服务B协作时,它得知道B在哪里。默认的服务发现系统是Consul,而multicast DNS (mdns,组播)机制作为本地解决方案,或者零依赖的P2P网络中的SWIM协议(gossip)。
- **负载均衡(Load Balancing)** - 在服务发现之上构建了负载均衡机制。当我们得到一个服务的任意多个的实例节点时,我们要一个机制去决定要路由到哪一个节点。我们使用随机处理过的哈希负载均衡机制来保证对服务请求颁发的均匀分布,并且在发生问题时进行重试。
- **消息编码(Message Encoding** - 支持基于内容类型(content-type)动态编码消息。客户端和服务端会一起使用content-type的格式来对Go进行无缝编/解码。各种各样的消息被编码会发送到不同的客户端,客户端服服务端默认会处理这些消息。content-type默认包含proto-rpc和json-rpc。
- **Request/Response** - RPC通信基于支持双向流的请求/响应方式,我们提供有抽象的同步通信机制。请求发送到服务时,会自动解析、负载均衡、拨号、转成字节流。默认的传输协议是http/1.1,而tls下使用http2协议。
- **异步消息(Async Messaging** - 发布订阅(PubSub)头等功能内置在异步通信与事件驱动架构中。事件通知在微服务开发中处于核心位置。默认的消息传送使用点到点http/1.1,激活tls时则使用http2。
- **可插拔接口(Pluggable Interfaces** - Go Micro为每个分布式系统抽象出接口。因此,Go Micro的接口都是可插拔的,允许其在运行时不可知的情况下仍可支持。所以只要实现接口,可以在内部使用任何的技术。更多插件请参考:[github.com/micro/go-plugins](https://github.com/micro/go-plugins)。
## 快速上手
更多关于架构、安装的资料可以查看[文档](https://micro.mu/docs/cn/)。
-79
View File
@@ -1,79 +0,0 @@
# Go Micro Roadmap
Go Micro is an **agent harness** and service framework for Go. A harness is the
runtime around an agent — the tools, memory, guardrails, workflows, state,
discovery, and protocols it needs to operate a system rather than just answer a
prompt. An agent is a distributed system — it discovers services, calls them,
holds state, and recovers from failure — so the harness is the runtime services
already have, and building an agent is building a service. The roadmap has two
jobs: make **agentic development** excellent, and make the **developer experience**
around it excellent.
The full, current roadmap lives at **[go-micro.dev/docs/roadmap](https://go-micro.dev/docs/roadmap)**
([source](internal/website/docs/roadmap.md)). The highlights:
## Where we are (v6)
Services, agents (`plan`/`delegate`, guardrails, memory, tool middleware), durable
flows, the MCP and A2A gateways (both directions, including A2A streaming,
push notifications, and multi-turn continuation), x402 paid tools, secure by
default.
## Principles
1. Build into what people run, never a separate product (no hosted platform, no
enterprise edition, no VC).
2. CLI-first — the CLI is the experience; UI must earn its place, never bloat.
3. The getting-started flow is a contract: *0→1* (scaffold → run → call) and
*0→hero* (a working multi-agent system) must always work and are verified on
every change.
4. Interaction matters as much as running — chatting with agents, inspecting runs
and history, end to end.
5. Battle-tested: works across every provider, fails safely, observable.
## Now — hardening
- **Cross-provider conformance** — the same agent scenario across all seven
providers, gated on keys, on a schedule.
- **Failure & resilience** — timeouts, rate limits, cancellation, deadline/context
propagation, retry/backoff.
- **Getting-started contract** — define and CI-verify the 0→1 and 0→hero flows.
## Next — agentic depth
- **Durable agent loop** — resume a long run via `Checkpoint` (flows already do).
- **Streaming** — broaden provider-backed `ai.Stream` coverage and keep chat/A2A streaming end to end.
- **Agent observability** — `RunInfo` → OpenTelemetry spans.
## Later
- Memory management (summarization, retrieval/RAG); human-in-the-loop pause/resume;
richer A2A live-stream reconnection (`tasks/resubscribe`) and `input-required`
handoffs.
## Developer experience (ongoing)
- A seamless CLI inner loop (scaffold → run → chat → inspect → deploy); UI
discipline (trim what isn't great); a maintained real-world example that doubles
as the 0→hero reference; docs kept in lockstep with the code.
## How it's sustained
The framework is the product, funded by sponsorship from those who run it — not a
hosted service, enterprise tier, or venture funding. See
[the v6 story](https://go-micro.dev/blog/27).
## Contributing & feedback
Pick an item, open an issue to discuss the approach, and submit a PR. Or join the
[Discord](https://discord.gg/G8Gk5j3uXr). Include tests, run `make test` and
`make lint`.
## Version support
- **v6** — active development (current).
- **v5** — security fixes only.
- **v4 and earlier** — end of life.
Major versions (v5 → v6) carry breaking changes; minors are backward-compatible.
See the [v5 → v6 migration guide](https://go-micro.dev/docs/guides/migration/v5-to-v6).
-179
View File
@@ -1,179 +0,0 @@
# Security Policy
## Supported Versions
We actively support the following versions of go-micro:
| Version | Supported |
| ------- | ------------------ |
| 5.x | :white_check_mark: |
| 4.x | :x: |
| 3.x | :x: |
| < 3.0 | :x: |
## Reporting a Vulnerability
**Please do not report security vulnerabilities through public GitHub issues.**
### How to Report
Send security vulnerability reports to: **security@go-micro.dev**
Or use GitHub's private security advisory feature:
https://github.com/micro/go-micro/security/advisories/new
### What to Include
Please include as much of the following information as possible:
- Type of vulnerability (e.g., RCE, XSS, SQL injection, etc.)
- Full paths of source file(s) related to the vulnerability
- Location of the affected source code (tag/branch/commit or direct URL)
- Step-by-step instructions to reproduce the issue
- Proof-of-concept or exploit code (if possible)
- Impact of the issue, including how an attacker might exploit it
### Response Timeline
- **Acknowledgment**: Within 48 hours
- **Initial Assessment**: Within 5 business days
- **Fix Timeline**: Depends on severity
- Critical: 7 days
- High: 14 days
- Medium: 30 days
- Low: Next release cycle
### Disclosure Policy
- We follow **coordinated disclosure**
- We'll work with you to understand and fix the issue
- We'll credit you in the security advisory (unless you prefer to remain anonymous)
- Please give us reasonable time to fix before public disclosure
- We'll publish a security advisory on GitHub when the fix is released
## Security Best Practices
When using go-micro in production:
### TLS/Transport Security
```go
import "go-micro.dev/v5/transport"
// Enable TLS verification (recommended)
os.Setenv("MICRO_TLS_SECURE", "true")
// Or use SecureConfig explicitly
tlsConfig := transport.SecureConfig()
```
See [TLS Security Update](internal/website/docs/TLS_SECURITY_UPDATE.md) for details.
### Authentication
```go
import "go-micro.dev/v5/auth"
// Use JWT authentication
service := micro.NewService(
micro.Auth(auth.NewAuth()),
)
```
### Input Validation
Always validate and sanitize inputs in your handlers:
```go
func (h *Handler) Create(ctx context.Context, req *Request, rsp *Response) error {
// Validate input
if req.Name == "" {
return errors.BadRequest("handler.create", "name is required")
}
// Sanitize and process
// ...
}
```
### Rate Limiting
Implement rate limiting for public-facing services:
```go
import "go-micro.dev/v5/client"
// Client-side rate limiting
client.NewClient(
client.RequestTimeout(time.Second * 5),
client.Retries(3),
)
```
### Secrets Management
Never commit secrets to version control:
```go
// Good: Use environment variables
apiKey := os.Getenv("API_KEY")
// Better: Use a secrets manager
import "github.com/hashicorp/vault/api"
```
### Dependency Security
Regularly update dependencies:
```bash
# Check for vulnerabilities
go list -json -m all | nancy sleuth
# Update dependencies
go get -u ./...
go mod tidy
```
## Known Security Considerations
### Reflection Usage
go-micro uses reflection for automatic handler registration. While this is a deliberate design choice for developer productivity, be aware:
- Type safety is enforced at runtime, not compile time
- Malformed requests won't crash services (errors are returned)
- See [Performance Considerations](internal/website/docs/performance.md)
### TLS Certificate Verification
**Default behavior in v5**: TLS certificate verification is **disabled** for backward compatibility.
**Production recommendation**: Enable secure mode:
```bash
export MICRO_TLS_SECURE=true
```
This will be the default in v6.
## Security Updates
Security updates are published as:
- GitHub Security Advisories
- Release notes with `[SECURITY]` prefix
- CVE entries for critical issues
Subscribe to releases: https://github.com/micro/go-micro/releases
## Bug Bounty
We currently do not offer a bug bounty program, but we greatly appreciate responsible disclosure and will publicly credit researchers who report valid security issues.
## Questions?
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
-29
View File
@@ -1,29 +0,0 @@
# Support
Go Micro is free and open source. There are two ways to get help: the community, and commercial support.
## Community support (free)
- **Documentation** — https://go-micro.dev/docs
- **Examples** — https://github.com/micro/go-micro/tree/master/examples
- **Bugs & features** — https://github.com/micro/go-micro/issues
- **Questions** — open a [Question](https://github.com/micro/go-micro/issues/new?template=question.md) issue
Community support is best-effort, from maintainers and contributors, with no response-time guarantees.
## Commercial support
If you're running Go Micro in production — or building agents and services on it and want a hand — paid support and consulting are available directly from the maintainer. This is what keeps the project maintained.
| Tier | For | What you get | How |
|------|-----|--------------|-----|
| **Community** | Everyone | Docs, examples, issues — best-effort | Free |
| **Sponsor** | Individuals & companies who rely on Go Micro | Back ongoing development; your name/logo in the README and on the site; a voice in priorities | [GitHub Sponsors](https://github.com/sponsors/asim) |
| **Support** | Teams running Go Micro in production | Priority responses, a direct line to the maintainer, prioritized bug fixes, upgrade & integration help | [Open a request](#get-in-touch) |
| **Consulting** | Teams building on Go Micro | Hands-on integration, architecture & agent-design review, training & onboarding, sponsored features | [Open a request](#get-in-touch) |
Recurring amounts are set on the [Sponsors page](https://github.com/sponsors/asim); support and consulting are scoped and quoted per engagement.
## Get in touch
Open a [**Commercial Support / Consulting**](https://github.com/micro/go-micro/issues/new?template=commercial_support.md) request — tell us what you're building, what you need, and your timeline, and we'll follow up. For anything you'd rather not discuss in public, become a [sponsor](https://github.com/sponsors/asim) and message privately.
-102
View File
@@ -1,102 +0,0 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/gateway/a2a"
)
func TestA2AStreamUsesAgentChatPathWithTools(t *testing.T) {
var sawTool bool
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
t.Fatal("model was not wired with agent tool handler")
}
result := opts.ToolHandler(ctx, ai.ToolCall{
ID: "call-1",
Name: "echo",
Input: map[string]any{"value": "a2a-stream"},
})
if !strings.Contains(result.Content, "a2a-stream-ok") {
t.Fatalf("tool result = %q, want marker", result.Content)
}
return &ai.Response{Answer: "streamed " + result.Content}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("stream-agent"), WithTool("echo", "echo text", nil, func(ctx context.Context, input map[string]any) (string, error) {
sawTool = true
if info, ok := ai.RunInfoFrom(ctx); !ok || info.RunID == "" || info.Agent != "stream-agent" {
t.Fatalf("RunInfo = %+v ok=%v, want stream-agent run", info, ok)
}
if input["value"] != "a2a-stream" {
t.Fatalf("tool input = %+v, want a2a-stream", input)
}
return "a2a-stream-ok", nil
}))
h := a2a.NewAgentStreamHandler(
a2a.Card("stream-agent", "http://example.invalid/stream-agent", "", nil),
func(ctx context.Context, text string) (string, error) {
resp, err := a.Ask(ctx, text)
if err != nil {
return "", err
}
return resp.Reply, nil
},
a.streamAskAI,
)
body := []byte(`{"jsonrpc":"2.0","id":1,"method":"message/stream","params":{"message":{"role":"user","parts":[{"kind":"text","text":"run stream tool"}],"kind":"message"}}}`)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if !sawTool {
t.Fatal("A2A stream did not execute the agent tool path")
}
if ct := rr.Result().Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
t.Fatalf("content-type = %q, want text/event-stream", ct)
}
if !strings.Contains(rr.Body.String(), "a2a-stream-ok") {
t.Fatalf("stream body missing tool marker: %s", rr.Body.String())
}
var final struct {
Result struct {
Status struct {
State string `json:"state"`
} `json:"status"`
Artifacts []struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"artifacts"`
} `json:"result"`
Error any `json:"error"`
}
for _, line := range strings.Split(strings.TrimSpace(rr.Body.String()), "\n") {
line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "data: "))
if line == "" {
continue
}
if err := json.Unmarshal([]byte(line), &final); err != nil {
t.Fatalf("decode event %q: %v", line, err)
}
}
if final.Error != nil {
t.Fatalf("final event error: %+v", final.Error)
}
if final.Result.Status.State != "completed" {
t.Fatalf("final state = %q, want completed", final.Result.Status.State)
}
if len(final.Result.Artifacts) != 1 || len(final.Result.Artifacts[0].Parts) != 1 || !strings.Contains(final.Result.Artifacts[0].Parts[0].Text, "a2a-stream-ok") {
t.Fatalf("final artifacts = %+v, want tool marker", final.Result.Artifacts)
}
}
+1 -566
View File
@@ -1,567 +1,2 @@
// Package agent provides the Agent abstraction for Go Micro.
//
// An Agent is a service with an LLM inside it. It registers a Chat
// RPC endpoint, discovers its assigned services' tools, and
// orchestrates them intelligently.
//
// agent := micro.NewAgent("task-mgr",
// micro.AgentServices("task"),
// micro.AgentPrompt("You manage tasks."),
// micro.AgentProvider("anthropic"),
// )
// agent.Run()
// Package agent provides an interface for building robots
package agent
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
pb "go-micro.dev/v6/agent/proto"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/gateway/a2a"
"go-micro.dev/v6/server"
"go-micro.dev/v6/store"
_ "go-micro.dev/v6/ai/anthropic"
_ "go-micro.dev/v6/ai/atlascloud"
_ "go-micro.dev/v6/ai/gemini"
_ "go-micro.dev/v6/ai/groq"
_ "go-micro.dev/v6/ai/mistral"
_ "go-micro.dev/v6/ai/ollama"
_ "go-micro.dev/v6/ai/openai"
_ "go-micro.dev/v6/ai/together"
)
// Agent is the interface for an AI agent that manages services.
type Agent interface {
Name() string
Init(...Option)
Options() Options
Ask(ctx context.Context, message string) (*Response, error)
Stream(ctx context.Context, message string) (ai.Stream, error)
Run() error
Stop() error
String() string
}
// Response is what an agent returns from Chat.
type Response struct {
Reply string
ToolCalls []ai.ToolCall
Agent string
// RunID correlates this Ask with tool calls, trace spans, and the
// persisted run timeline. ParentID is set when this response belongs
// to a delegated sub-agent run.
RunID string
ParentID string
}
type agentImpl struct {
opts Options
model ai.Model
tools *ai.Tools
mem Memory
server server.Server
mu sync.Mutex
// ephemeral marks a short-lived sub-agent created by delegation.
// Ephemeral agents run with an isolated context: they load and
// persist no history, and have no built-in tools (so they cannot
// plan or re-delegate).
ephemeral bool
// steps counts tool executions in the current Ask, for MaxSteps.
steps int
// calls counts identical tool calls (name+args) in the current Ask,
// for LoopLimit.
calls map[string]int
// runID correlates the tool calls of the current Ask; parentRunID is
// the run that delegated to this one (set on ephemeral sub-agents).
// Both are surfaced to tool wrappers via ai.RunInfo on the context.
runID string
parentRunID string
// pause records a guardrail approval pause raised during the current
// Ask. The model provider only sees a refused tool result; the agent
// converts it into a durable paused run instead of completing the run.
pause *approvalPause
// currentRun points at the checkpoint record for the Ask currently
// holding mu. Tool execution updates it so resumed runs can reuse
// completed tool results without replaying side effects.
currentRun *flow.Run
}
// New creates a new Agent.
func New(opts ...Option) Agent {
return &agentImpl{
opts: newOptions(opts...),
}
}
// newEphemeral creates a short-lived sub-agent for a delegated subtask.
// It shares the parent's provider, model, and infrastructure but runs
// with an isolated context: it loads and persists no history and has no
// built-in tools (so it can neither plan nor re-delegate). Returns the
// concrete type because ephemeral is an internal construction detail,
// not a public option.
func newEphemeral(opts ...Option) *agentImpl {
return &agentImpl{
opts: newOptions(opts...),
ephemeral: true,
}
}
func (a *agentImpl) Name() string {
return a.opts.Name
}
func (a *agentImpl) Init(opts ...Option) {
for _, o := range opts {
o(&a.opts)
}
a.setup()
}
func (a *agentImpl) Options() Options {
return a.opts
}
func (a *agentImpl) String() string {
return "agent"
}
func (a *agentImpl) setup() {
a.setupWithToolHandler(nil)
}
func (a *agentImpl) setupWithToolHandler(handler ai.ToolHandler) {
var modelOpts []ai.Option
modelOpts = append(modelOpts, ai.WithAPIKey(a.opts.APIKey))
if a.opts.Model != "" {
modelOpts = append(modelOpts, ai.WithModel(a.opts.Model))
}
if a.opts.BaseURL != "" {
modelOpts = append(modelOpts, ai.WithBaseURL(a.opts.BaseURL))
}
// Reuse the existing tools instance: its name map is populated by
// discoverTools, and rebuilding it here would orphan a base handler that
// already captured the old instance (breaking StreamAsk tool resolution).
if a.tools == nil {
a.tools = ai.NewTools(a.opts.Registry, ai.ToolClient(a.opts.Client))
}
if handler == nil {
handler = a.toolHandler()
}
modelOpts = append(modelOpts, ai.WithToolHandler(handler))
a.model = ai.New(a.opts.Provider, modelOpts...)
if a.model != nil {
a.model = a.tracedModel(a.model)
}
if a.mem != nil {
return
}
// Memory is pluggable. Use the configured one, otherwise the default
// store-backed memory — except ephemeral sub-agents, which keep an
// isolated, non-persistent context.
switch {
case a.opts.Memory != nil:
a.mem = a.opts.Memory
case a.ephemeral:
a.mem = NewInMemory(a.opts.HistoryLimit)
case a.opts.MemoryCompaction.MaxMessages > 0:
a.mem = NewCompactingMemoryWithOptions(a.stateStore(), "history", a.opts.MemoryCompaction)
case a.opts.MemoryRetrievalLimit > 0:
a.mem = NewRetrievalMemory(a.stateStore(), "history", a.opts.MemoryRetrievalLimit)
default:
a.mem = NewMemory(a.stateStore(), "history", a.opts.HistoryLimit)
}
}
// stateStore returns the agent's own state store, scoped to its name so
// memory and plan live in their own table ("agent/{name}") rather than a
// shared global one. The scoped handle injects the database/table per
// operation without mutating the underlying store.
func (a *agentImpl) stateStore() store.Store {
s := a.opts.Store
if s == nil {
s = store.DefaultStore
}
return store.Scope(s, "agent", a.opts.Name)
}
// Ask sends a message and returns the agent's response.
// This is the programmatic API for direct use.
func (a *agentImpl) Ask(ctx context.Context, message string) (*Response, error) {
return a.ask(ctx, message, a.parentRunID)
}
// Stream sends a message and returns a streaming model response. Tool-calling
// agent runs still use Ask; Stream is for chat turns where immediate token
// delivery is more important than tool orchestration.
func (a *agentImpl) Stream(ctx context.Context, message string) (ai.Stream, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.model == nil {
a.setup()
}
toolList, err := a.discoverTools()
if err != nil {
return nil, fmt.Errorf("discover tools: %w", err)
}
a.mem.Add("user", message)
stream, err := a.model.Stream(ctx, &ai.Request{
Prompt: message,
SystemPrompt: a.buildPrompt(),
Tools: toolList,
Messages: a.mem.Messages(),
})
if err != nil {
return nil, err
}
return &memoryRecordingStream{stream: stream, memory: a.mem}, nil
}
// Pending returns checkpointed agent runs that have not completed. It mirrors
// flow.Pending for startup recovery loops that drain durable agent work.
func Pending(ctx context.Context, ag Agent) ([]flow.Run, error) {
a, ok := ag.(*agentImpl)
if !ok {
return nil, fmt.Errorf("agent pending: unsupported agent implementation %T", ag)
}
return a.pending(ctx)
}
func (a *agentImpl) ask(ctx context.Context, message, parentRunID string) (*Response, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.model == nil {
a.setup()
}
return a.askLocked(ctx, uuid.New().String(), message, parentRunID, nil, true)
}
func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID string, existing *flow.Run, addUserMessage bool) (*Response, error) {
toolList, err := a.discoverTools()
if err != nil {
return nil, fmt.Errorf("discover tools: %w", err)
}
if addUserMessage {
a.mem.Add("user", message)
}
a.steps = 0
a.calls = map[string]int{}
a.pause = nil
// Correlate this run's tool calls and surface lineage to wrappers.
a.runID = runID
ctx = ai.WithRunInfo(ctx, ai.RunInfo{
RunID: a.runID,
ParentID: parentRunID,
Agent: a.opts.Name,
})
run := a.newCheckpointRun(runID, message, parentRunID, existing)
a.currentRun = &run
defer func() { a.currentRun = nil }()
if err := a.saveRun(ctx, run); err != nil {
return nil, err
}
ctx, endRun := a.startRun(ctx, message)
if existing != nil {
a.recordTimelineEvent(ctx, RunEvent{Time: time.Now(), RunID: runID, ParentID: parentRunID, Agent: a.opts.Name, Kind: "resume", Name: run.State.Stage})
}
defer func() { endRun(err) }()
messages := a.mem.Messages()
if recall, ok := a.mem.(MemoryRecall); ok && a.opts.MemoryRecallLimit > 0 {
if recalled := recall.Recall(message, a.opts.MemoryRecallLimit); len(recalled) > 0 {
messages = append([]ai.Message{{
Role: "system",
Content: "Relevant recalled memory follows; use it as durable prior context without assuming the whole conversation was replayed.",
}}, append(recalled, messages...)...)
}
}
const maxPlanCompletionTurns = 3
var resp *ai.Response
for planCompletionTurn := 0; ; planCompletionTurn++ {
resp, err = ai.GenerateWithRetry(ctx, a.model, &ai.Request{
Prompt: message,
SystemPrompt: a.buildPrompt(),
Tools: toolList,
Messages: messages,
}, ai.GeneratePolicy{
Timeout: a.opts.ModelTimeout,
MaxAttempts: a.opts.ModelMaxAttempts,
Backoff: a.opts.ModelRetryBackoff,
})
if err != nil {
run.Status = agentRunFailureStatus(err)
err = agentOperationalError(err)
if a.currentRun != nil {
run.Steps = a.currentRun.Steps
}
if len(run.Steps) == 0 {
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
}
run.Steps[0].Status = run.Status
run.Steps[0].Error = err.Error()
_ = a.saveRun(ctx, run)
return nil, err
}
if a.pause != nil && a.opts.Checkpoint != nil {
run.Status = "paused"
run.State.Stage = agentApprovalStep
run.State.Data = []byte(message)
if a.pause.Tool == toolHumanInput {
run.State.Stage = agentInputStep
_ = run.State.Set(inputPause{OriginalMessage: message, Prompt: a.pause.Message})
}
run.Steps[0].Status = "paused"
run.Steps[0].Error = a.pause.Message
run.Steps[0].Result = a.pause.Tool
if err := a.saveRun(ctx, run); err != nil {
return nil, err
}
return nil, fmt.Errorf("agent run %s paused for approval: %s", run.ID, a.pause.Message)
}
if len(resp.ToolCalls) == 0 {
if calls, answer, ok := a.executeTextToolCalls(ctx, resp.Reply, toolList); ok {
resp.ToolCalls = calls
if resp.Answer == "" {
resp.Answer = answer
}
trimmedReply := strings.TrimSpace(resp.Reply)
if strings.HasPrefix(trimmedReply, "{") || strings.HasPrefix(trimmedReply, "[") || strings.HasPrefix(trimmedReply, "```") {
resp.Reply = ""
}
}
}
if a.opts.Checkpoint != nil {
if unfinished := a.unfinishedPlanSteps(); len(unfinished) > 0 && planCompletionTurn < maxPlanCompletionTurns {
if resp.Reply != "" {
a.mem.Add("assistant", resp.Reply)
}
if resp.Answer != "" {
a.mem.Add("assistant", resp.Answer)
}
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
}
}
break
}
if resp.Reply != "" {
a.mem.Add("assistant", resp.Reply)
}
if resp.Answer != "" {
a.mem.Add("assistant", resp.Answer)
}
reply := resp.Reply
if resp.Answer != "" {
if reply != "" {
reply += "\n\n"
}
reply += resp.Answer
}
res := &Response{
Reply: reply,
ToolCalls: resp.ToolCalls,
Agent: a.opts.Name,
RunID: a.runID,
ParentID: parentRunID,
}
if a.opts.Checkpoint != nil {
if unfinished := a.unfinishedPlanSteps(); len(unfinished) > 0 {
err = fmt.Errorf("agent run %s has unfinished plan steps: %s", run.ID, strings.Join(unfinished, ", "))
run.Status = "failed"
run.State.Stage = agentAskStep
run.State.Data = []byte(message)
if a.currentRun != nil {
run.Steps = a.currentRun.Steps
}
if len(run.Steps) == 0 {
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
}
run.Steps[0].Status = "failed"
run.Steps[0].Error = err.Error()
_ = a.saveRun(ctx, run)
return nil, err
}
}
run.Status = "done"
run.State.Stage = ""
if b, marshalErr := json.Marshal(res); marshalErr == nil {
run.State.Data = b
}
if a.currentRun != nil {
run.Steps = a.currentRun.Steps
}
if len(run.Steps) == 0 {
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
}
run.Steps[0].Status = "done"
run.Steps[0].Attempts++
run.Steps[0].Result = reply
if err := a.saveRun(ctx, run); err != nil {
return nil, err
}
return res, nil
}
// Chat implements the proto AgentHandler interface for RPC.
// @example {"message": "What tasks are overdue?"}
func (a *agentImpl) Chat(ctx context.Context, req *pb.ChatRequest, rsp *pb.ChatResponse) error {
resp, err := a.ask(ctx, req.Message, req.ParentId)
if err != nil {
return err
}
rsp.Reply = resp.Reply
rsp.Agent = resp.Agent
rsp.RunId = resp.RunID
rsp.ParentId = resp.ParentID
for _, tc := range resp.ToolCalls {
input, _ := json.Marshal(tc.Input)
rsp.ToolCalls = append(rsp.ToolCalls, &pb.ToolCall{
Id: tc.ID,
Name: tc.Name,
Input: string(input),
Result: tc.Result,
})
}
return nil
}
// Run starts the agent as a service with a Chat RPC endpoint.
func (a *agentImpl) Run() error {
if a.model == nil {
a.setup()
}
a.server = server.NewServer(
server.Name(a.opts.Name),
server.Address(a.opts.Address),
server.Registry(a.opts.Registry),
server.Metadata(map[string]string{
"type": "agent",
"services": strings.Join(a.opts.Services, ","),
}),
)
_ = pb.RegisterAgentHandler(a.server, a)
if err := a.server.Start(); err != nil {
return fmt.Errorf("failed to start agent: %w", err)
}
fmt.Printf("Agent %s registered (manages: %s)\n", a.opts.Name, strings.Join(a.opts.Services, ", "))
// Optionally serve the agent directly over the A2A protocol, calling
// Ask in-process — no separate gateway needed to be queried by URL.
if a.opts.A2AAddress != "" {
card := a2a.Card(a.opts.Name, "http://localhost"+a.opts.A2AAddress, "", a.opts.Services)
handler := a2a.NewAgentStreamHandler(card, func(ctx context.Context, text string) (string, error) {
resp, err := a.Ask(ctx, text)
if err != nil {
return "", err
}
return resp.Reply, nil
}, a.streamAskAI)
go func() {
if err := http.ListenAndServe(a.opts.A2AAddress, handler); err != nil {
fmt.Printf("agent %s A2A server: %v\n", a.opts.Name, err)
}
}()
fmt.Printf("Agent %s serving A2A on %s\n", a.opts.Name, a.opts.A2AAddress)
}
ch := make(chan struct{})
<-ch
return nil
}
func (a *agentImpl) Stop() error {
if a.server != nil {
return a.server.Stop()
}
return nil
}
func (a *agentImpl) discoverTools() ([]ai.Tool, error) {
all, err := a.tools.Discover()
if err != nil {
return nil, err
}
var scoped []ai.Tool
for _, t := range all {
if strings.HasPrefix(t.OriginalName, a.opts.Name+".") {
continue
}
if len(a.opts.Services) == 0 {
scoped = append(scoped, t)
continue
}
for _, svc := range a.opts.Services {
if strings.HasPrefix(t.OriginalName, svc+".") {
scoped = append(scoped, t)
break
}
}
}
// Developer-registered custom tools (WithTool).
for i := range a.opts.tools {
scoped = append(scoped, a.opts.tools[i].def)
}
// Expose the agent's own capabilities (plan, delegate) as tools.
// Ephemeral sub-agents don't get them.
if !a.ephemeral {
scoped = append(scoped, builtinTools()...)
}
return scoped, nil
}
func (a *agentImpl) buildPrompt() string {
var base string
switch {
case a.opts.Prompt != "":
base = a.opts.Prompt
case len(a.opts.Services) > 0:
base = fmt.Sprintf("You are the %s agent. You manage these services: %s. Use the available tools to fulfill requests.",
a.opts.Name, strings.Join(a.opts.Services, ", "))
default:
base = fmt.Sprintf("You are the %s agent. Use the available tools to fulfill requests.", a.opts.Name)
}
// Keep the agent oriented: surface its saved plan, if any.
if !a.ephemeral {
if plan := a.loadPlan(); plan != "" {
base += "\n\nYour current plan (update it with the plan tool as you make progress):\n" + plan
}
}
return base
}
-137
View File
@@ -1,137 +0,0 @@
package agent
import (
"context"
"testing"
pb "go-micro.dev/v6/agent/proto"
"go-micro.dev/v6/ai"
)
func TestNew(t *testing.T) {
a := New(
Name("test-agent"),
Services("task", "project"),
Prompt("You manage tasks."),
Provider("anthropic"),
)
if a.Name() != "test-agent" {
t.Errorf("Name() = %q, want %q", a.Name(), "test-agent")
}
opts := a.Options()
if opts.Provider != "anthropic" {
t.Errorf("Provider = %q, want %q", opts.Provider, "anthropic")
}
if len(opts.Services) != 2 {
t.Fatalf("Services = %v, want 2 items", opts.Services)
}
if opts.Services[0] != "task" || opts.Services[1] != "project" {
t.Errorf("Services = %v, want [task project]", opts.Services)
}
if opts.Prompt != "You manage tasks." {
t.Errorf("Prompt = %q, want %q", opts.Prompt, "You manage tasks.")
}
if opts.HistoryLimit != 50 {
t.Errorf("HistoryLimit = %d, want 50", opts.HistoryLimit)
}
}
func TestChatResponseIncludesRunIDs(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
return &ai.Response{Reply: "ok"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("chat-run"))
var rsp pb.ChatResponse
if err := a.Chat(context.Background(), &pb.ChatRequest{Message: "hello"}, &rsp); err != nil {
t.Fatalf("Chat: %v", err)
}
if rsp.RunId == "" {
t.Fatal("Chat response RunId is empty")
}
if rsp.Agent != "chat-run" {
t.Errorf("Agent = %q, want chat-run", rsp.Agent)
}
if rsp.ParentId != "" {
t.Errorf("ParentId = %q, want empty", rsp.ParentId)
}
}
func TestChatRequestParentIDPropagatesToResponse(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
info, ok := ai.RunInfoFrom(ctx)
if !ok {
t.Fatal("RunInfo missing from model context")
}
if info.ParentID != "flow-run-123" {
t.Fatalf("RunInfo.ParentID = %q, want flow-run-123", info.ParentID)
}
return &ai.Response{Reply: "ok"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("chat-child"))
var rsp pb.ChatResponse
if err := a.Chat(context.Background(), &pb.ChatRequest{Message: "hello", ParentId: "flow-run-123"}, &rsp); err != nil {
t.Fatalf("Chat: %v", err)
}
if rsp.ParentId != "flow-run-123" {
t.Errorf("ParentId = %q, want flow-run-123", rsp.ParentId)
}
}
func TestBuildPrompt(t *testing.T) {
// Custom prompt
a := New(Name("test"), Prompt("custom prompt")).(*agentImpl)
if got := a.buildPrompt(); got != "custom prompt" {
t.Errorf("buildPrompt() = %q, want %q", got, "custom prompt")
}
// Auto-generated prompt with services
a = New(Name("test"), Services("task", "project")).(*agentImpl)
got := a.buildPrompt()
if got == "" {
t.Error("buildPrompt() returned empty")
}
if !contains(got, "task") || !contains(got, "project") {
t.Errorf("buildPrompt() = %q, should mention services", got)
}
// Auto-generated prompt without services
a = New(Name("test")).(*agentImpl)
got = a.buildPrompt()
if !contains(got, "test") {
t.Errorf("buildPrompt() = %q, should mention agent name", got)
}
}
func TestDefaults(t *testing.T) {
a := New(Name("test"))
opts := a.Options()
if opts.Registry == nil {
t.Error("Registry should default to DefaultRegistry")
}
if opts.Client == nil {
t.Error("Client should default to DefaultClient")
}
if opts.Store == nil {
t.Error("Store should default to DefaultStore")
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub))
}
func containsStr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
-581
View File
@@ -1,581 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"go-micro.dev/v6/ai"
codecBytes "go-micro.dev/v6/codec/bytes"
"go-micro.dev/v6/gateway/a2a"
"go-micro.dev/v6/store"
)
// Built-in agent tools. These are not service endpoints — they are
// capabilities the agent has over itself: maintaining a plan in its
// memory, and delegating a subtask to another agent.
//
// They are plain tools, wired into the agent's tool handler alongside
// the discovered service tools. There is no separate harness or graph:
// the LLM calls them like any other tool.
const (
toolPlan = "plan"
toolDelegate = "delegate"
toolHumanInput = "request_input"
)
// builtinTools returns the tool definitions exposed to the model in
// addition to the agent's scoped service tools.
func builtinTools() []ai.Tool {
return []ai.Tool{
{
Name: toolPlan,
OriginalName: toolPlan,
Description: "Record or update your plan as an ordered list of steps before doing multi-step work. " +
"Call this whenever the plan changes. The plan is saved to your memory and shown back to you on later turns.",
Properties: map[string]any{
"steps": map[string]any{
"type": "array",
"description": "Ordered plan steps. Each step has a 'task' (string) and a " +
"'status' (one of: pending, in_progress, done).",
},
},
},
{
Name: toolHumanInput,
OriginalName: toolHumanInput,
Description: "Pause this agent run when you need missing information, a decision, or other human input before you can continue. " +
"The run is checkpointed as input-required and can be resumed with the human response without losing completed tool history.",
Properties: map[string]any{
"prompt": map[string]any{
"type": "string",
"description": "The specific question, decision, or instruction needed from the human operator.",
},
},
},
{
Name: toolDelegate,
OriginalName: toolDelegate,
Description: "Delegate a self-contained subtask to another agent. If 'to' names an agent that already " +
"manages the relevant services, that agent handles it; otherwise a focused sub-agent is created for the " +
"subtask. The sub-agent works in an isolated context and returns only its result. Use this to keep your " +
"own context focused and to let domain experts handle their own services.",
Properties: map[string]any{
"task": map[string]any{
"type": "string",
"description": "The subtask to delegate, described completely and self-contained.",
},
"to": map[string]any{
"type": "string",
"description": "Optional. The agent or service name best suited to the subtask, or the URL of an external agent that speaks the A2A protocol.",
},
},
},
}
}
// Builtins returns the built-in agent tools (plan, delegate) together
// with a handler for them, so the same capabilities can be wired into a
// tool loop that isn't a running Agent — for example the `micro chat`
// fallback. The handler's third return value is false when the name is
// not a built-in, so callers can fall through to their own tools.
//
// Configure it with the same options as an Agent (Name, Provider,
// WithStore, WithRegistry, WithClient, ...); these back plan's memory
// and delegate's RPC/sub-agent behavior.
func Builtins(opts ...Option) (tools []ai.Tool, handle func(name string, input map[string]any) (result any, content string, ok bool)) {
a := &agentImpl{opts: newOptions(opts...)}
handle = func(name string, input map[string]any) (any, string, bool) {
switch name {
case toolPlan:
r := a.handlePlan(ai.ToolCall{Name: name, Input: input})
return r.Value, r.Content, true
case toolHumanInput:
r := a.handleHumanInput(ai.ToolCall{Name: name, Input: input})
return r.Value, r.Content, true
case toolDelegate:
r := a.handleDelegate(context.Background(), ai.ToolCall{Name: name, Input: input})
return r.Value, r.Content, true
}
return nil, "", false
}
return builtinTools(), handle
}
// toolHandler returns the agent's tool-call handler, composed as a stack
// of wrappers around a base handler — the same middleware shape as
// client/server wrappers. The base executes the call (custom tools,
// delegate, or RPC); the built-in guardrails wrap it; developer wrappers
// (WrapTool) wrap those, outermost, so they observe every call and its
// result including guardrail refusals. Ephemeral sub-agents get the bare
// service handler so they can neither plan nor re-delegate (which
// prevents runaway recursion).
func (a *agentImpl) toolHandler() ai.ToolHandler {
if a.ephemeral {
return a.toolTimeoutWrap(a.tools.Handler())
}
// Innermost first: base, then guardrails (approve → loop → step →
// plan), then developer wrappers outermost. Wrapping reverses order,
// so the result runs plan → step → loop → approve → checkpoint → base.
h := a.baseHandler()
h = a.toolTimeoutWrap(h)
h = a.toolRetryWrap(h)
h = a.checkpointToolWrap(h)
h = a.approveWrap(h)
h = a.loopWrap(h)
h = a.stepWrap(h)
h = a.planWrap(h)
h = contextWrap(h)
h = a.traceTool(h)
for i := len(a.opts.wrappers) - 1; i >= 0; i-- {
h = a.opts.wrappers[i](h)
}
return h
}
// contextWrap stops tool execution promptly when the Ask context has
// already been canceled or its deadline has expired. This keeps guardrail
// bookkeeping and side-effecting tools from running after the caller has
// abandoned the agent run.
func contextWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
select {
case <-ctx.Done():
return errResult(call.ID, ctx.Err().Error())
default:
}
return next(ctx, call)
}
}
// toolTimeoutWrap gives each tool execution its own deadline while preserving
// caller cancellation. Handlers still execute synchronously; tools that honor
// context (custom tools, delegate RPC/A2A, and go-micro RPC clients) return
// promptly with a bounded error result when the deadline expires.
func (a *agentImpl) toolTimeoutWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if a.opts.ToolTimeout <= 0 {
return next(ctx, call)
}
toolCtx, cancel := context.WithTimeout(ctx, a.opts.ToolTimeout)
defer cancel()
return next(toolCtx, call)
}
}
// toolRetryWrap retries transient tool failures with bounded backoff. It is
// opt-in because tools can have side effects; guardrail refusals and caller
// cancellation are never retried.
func (a *agentImpl) toolRetryWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
maxAttempts := a.opts.ToolMaxAttempts
if maxAttempts <= 0 {
maxAttempts = 1
}
var res ai.ToolResult
for attempt := 1; attempt <= maxAttempts; attempt++ {
if err := ctx.Err(); err != nil {
return errResult(call.ID, err.Error())
}
res = next(ctx, call)
if !retryableToolResult(res) || attempt == maxAttempts || ctx.Err() != nil {
return annotateToolAttempts(res, attempt)
}
t := time.NewTimer(toolRetryBackoff(attempt, a.opts.ToolRetryBackoff))
select {
case <-ctx.Done():
if !t.Stop() {
<-t.C
}
return errResult(call.ID, ctx.Err().Error())
case <-t.C:
}
}
return annotateToolAttempts(res, maxAttempts)
}
}
func retryableToolResult(res ai.ToolResult) bool {
if res.Refused != "" {
return false
}
msg := toolErrorMessage(res)
if msg == "" {
return false
}
return ai.IsTransientError(fmt.Errorf("%s", msg))
}
func toolErrorMessage(res ai.ToolResult) string {
if m, ok := res.Value.(map[string]string); ok {
return m["error"]
}
if m, ok := res.Value.(map[string]any); ok {
if v, ok := m["error"].(string); ok {
return v
}
}
var decoded map[string]string
if err := json.Unmarshal([]byte(res.Content), &decoded); err == nil {
return decoded["error"]
}
return ""
}
func annotateToolAttempts(res ai.ToolResult, attempts int) ai.ToolResult {
if attempts <= 1 {
return res
}
res.Attempts = attempts
if m, ok := res.Value.(map[string]string); ok {
cp := map[string]any{}
for k, v := range m {
cp[k] = v
}
cp["attempts"] = attempts
res.Value = cp
if b, err := json.Marshal(cp); err == nil {
res.Content = string(b)
}
}
return res
}
func toolRetryBackoff(attempt int, base time.Duration) time.Duration {
if base <= 0 {
base = 200 * time.Millisecond
}
if shift := attempt - 1; shift > 0 {
base <<= shift
}
if base > 30*time.Second {
return 30 * time.Second
}
return base
}
// baseHandler executes a tool call: a developer custom tool, the built-in
// delegate, or an RPC to the service. It is the innermost handler.
func (a *agentImpl) baseHandler() ai.ToolHandler {
rpc := a.tools.Handler()
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
for i := range a.opts.tools {
if a.opts.tools[i].def.Name == call.Name {
out, err := a.opts.tools[i].handler(ctx, call.Input)
if err != nil {
return errResult(call.ID, err.Error())
}
return ai.ToolResult{ID: call.ID, Value: out, Content: out}
}
}
if call.Name == toolHumanInput {
return a.handleHumanInput(call)
}
if call.Name == toolDelegate {
return a.handleDelegate(ctx, call)
}
return rpc(ctx, call)
}
}
// planWrap handles the plan tool inline. plan is internal bookkeeping,
// not an action — it is never counted, loop-checked, or gated.
func (a *agentImpl) planWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if call.Name == toolPlan {
return a.handlePlan(call)
}
res := next(ctx, call)
if res.Refused == "" && toolErrorMessage(res) == "" {
a.completeNextPlanStep()
}
return res
}
}
// stepWrap bounds the number of actions per Ask (MaxSteps).
func (a *agentImpl) stepWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if a.opts.MaxSteps > 0 {
a.steps++
if a.steps > a.opts.MaxSteps {
return refused(call.ID, ai.RefusedMaxSteps, fmt.Sprintf(
"step limit reached (%d). Do not call any more tools; stop and summarize what you have so far.",
a.opts.MaxSteps))
}
}
return next(ctx, call)
}
}
// loopWrap stops the agent repeating an identical action that makes no
// progress (which the step count alone won't catch).
func (a *agentImpl) loopWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if a.opts.LoopLimit > 0 {
if a.calls == nil {
a.calls = map[string]int{}
}
args, _ := json.Marshal(call.Input)
fp := call.Name + ":" + string(args)
a.calls[fp]++
if a.calls[fp] > a.opts.LoopLimit {
return refused(call.ID, ai.RefusedLoop, fmt.Sprintf(
"loop detected: you have already called %q with the same arguments %d times and the result will not change. Stop repeating it — try a different approach, or finish with what you have.",
call.Name, a.opts.LoopLimit))
}
}
return next(ctx, call)
}
}
// approveWrap gates each action before it runs (ApproveTool).
type approvalPause struct {
Tool string
Message string
}
type inputPause struct {
OriginalMessage string `json:"original_message"`
Prompt string `json:"prompt"`
}
func (a *agentImpl) approveWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if a.opts.Approve != nil {
if ok, reason := a.opts.Approve(call.Name, call.Input); !ok {
msg := "tool call was not approved"
if reason != "" {
msg += ": " + reason
}
a.pause = &approvalPause{Tool: call.Name, Message: msg}
return refused(call.ID, ai.RefusedApproval, msg)
}
}
return next(ctx, call)
}
}
// 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 {
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: call.Input, Content: string(data)}
}
func (a *agentImpl) completeNextPlanStep() {
plan := a.loadPlan()
if plan == "" {
return
}
var data map[string]any
if err := json.Unmarshal([]byte(plan), &data); err != nil {
return
}
steps, ok := data["steps"].([]any)
if !ok {
return
}
for _, raw := range steps {
step, ok := raw.(map[string]any)
if !ok {
continue
}
status, _ := step["status"].(string)
if status == "" || status == "pending" || status == "in_progress" {
step["status"] = "done"
b, err := json.Marshal(data)
if err == nil {
_ = a.stateStore().Write(&store.Record{Key: planKey, Value: b})
}
return
}
}
}
func (a *agentImpl) unfinishedPlanSteps() []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
}
status, _ := step["status"].(string)
if status != "" && status != "pending" && status != "in_progress" {
continue
}
task, _ := step["task"].(string)
if task == "" {
task = "<unnamed>"
}
unfinished = append(unfinished, task)
}
return unfinished
}
// handleHumanInput records that the model needs operator input before it can continue.
func (a *agentImpl) handleHumanInput(call ai.ToolCall) ai.ToolResult {
prompt, _ := call.Input["prompt"].(string)
prompt = strings.TrimSpace(prompt)
if prompt == "" {
prompt = "human input required"
}
a.pause = &approvalPause{Tool: toolHumanInput, Message: prompt}
return refused(call.ID, ai.RefusedApproval, "input-required: "+prompt)
}
// handleDelegate hands a subtask to another agent. Delegate-first:
// 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) ai.ToolResult {
input := call.Input
task, _ := input["task"].(string)
if task == "" {
return errResult(call.ID, "task is required")
}
to, _ := input["to"].(string)
// An external agent on another framework, addressed by A2A URL.
if strings.HasPrefix(to, "http://") || strings.HasPrefix(to, "https://") {
reply, err := a2a.NewClient(to).Send(ctx, task)
if err != nil {
return errResult(call.ID, "delegate to A2A agent "+to+": "+err.Error())
}
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.
if to != "" && a.isAgent(to) {
reply, err := a.callAgentRPC(ctx, to, task)
if err != nil {
return errResult(call.ID, "delegate to agent "+to+": "+err.Error())
}
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:
// it loads no history and persists none.
var svcs []string
if to != "" {
svcs = []string{to}
}
sub := newEphemeral(
Name(a.opts.Name+".sub"),
Services(svcs...),
Prompt("You are a sub-agent handling a single delegated subtask. "+
"Complete it using the available tools and report the result concisely."),
Provider(a.opts.Provider),
Model(a.opts.Model),
APIKey(a.opts.APIKey),
WithRegistry(a.opts.Registry),
WithClient(a.opts.Client),
WithStore(a.opts.Store),
ModelCallTimeout(a.opts.ModelTimeout),
ModelRetry(a.opts.ModelMaxAttempts, a.opts.ModelRetryBackoff),
ToolCallTimeout(a.opts.ToolTimeout),
ToolRetry(a.opts.ToolMaxAttempts, a.opts.ToolRetryBackoff),
TraceProvider(a.opts.TraceProvider),
)
// Record lineage so the sub-agent's tool calls carry this run as parent.
sub.parentRunID = a.runID
resp, err := sub.Ask(ctx, task)
if err != nil {
return errResult(call.ID, "sub-agent: "+err.Error())
}
out := map[string]any{"reply": resp.Reply}
b, _ := json.Marshal(out)
return ai.ToolResult{ID: call.ID, Value: out, Content: string(b)}
}
// isAgent reports whether name resolves to a registered agent (a
// service advertising type=agent in its metadata).
func (a *agentImpl) isAgent(name string) bool {
if a.opts.Registry == nil {
return false
}
recs, err := a.opts.Registry.GetService(name)
if err != nil || len(recs) == 0 {
return false
}
if recs[0].Metadata != nil && recs[0].Metadata["type"] == "agent" {
return true
}
for _, n := range recs[0].Nodes {
if n.Metadata != nil && n.Metadata["type"] == "agent" {
return true
}
}
return false
}
// callAgentRPC calls another agent's Agent.Chat endpoint and returns
// its reply.
func (a *agentImpl) callAgentRPC(ctx context.Context, name, msg string) (string, error) {
body, _ := json.Marshal(map[string]string{"message": msg})
req := a.opts.Client.NewRequest(name, "Agent.Chat", &codecBytes.Frame{Data: body})
var rsp codecBytes.Frame
if err := a.opts.Client.Call(ctx, req, &rsp); err != nil {
return "", err
}
var out struct {
Reply string `json:"reply"`
}
if err := json.Unmarshal(rsp.Data, &out); err != nil {
return "", err
}
return out.Reply, nil
}
// planKey is the record key for an agent's plan within its scoped store.
const planKey = "plan"
// loadPlan returns the stored plan as a JSON string, or "" if none.
func (a *agentImpl) loadPlan() string {
recs, err := a.stateStore().Read(planKey)
if err != nil || len(recs) == 0 {
return ""
}
return string(recs[0].Value)
}
func errResult(id, msg string) ai.ToolResult {
m := map[string]string{"error": msg}
b, _ := json.Marshal(m)
return ai.ToolResult{ID: id, Value: m, Content: string(b)}
}
// refused is an error result a guardrail returns, tagged with a structured
// reason (ai.Refused*) so a tool wrapper can react to it without parsing
// the message.
func refused(id, reason, msg string) ai.ToolResult {
r := errResult(id, msg)
r.Refused = reason
return r
}
-167
View File
@@ -1,167 +0,0 @@
package agent
import (
"encoding/json"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
func TestBuiltinTools(t *testing.T) {
tools := builtinTools()
if len(tools) != 3 {
t.Fatalf("builtinTools() = %d tools, want 3", len(tools))
}
names := map[string]bool{}
for _, tl := range tools {
names[tl.Name] = true
}
if !names[toolPlan] || !names[toolDelegate] || !names[toolHumanInput] {
t.Errorf("builtin tools = %v, want plan, request_input, and delegate", names)
}
}
func TestHandlePlanPersists(t *testing.T) {
mem := store.NewMemoryStore()
a := New(Name("planner"), WithStore(mem)).(*agentImpl)
steps := map[string]any{
"steps": []any{
map[string]any{"task": "gather requirements", "status": "done"},
map[string]any{"task": "write code", "status": "in_progress"},
},
}
content := a.handlePlan(ai.ToolCall{Name: "plan", Input: steps}).Content
if content == "" {
t.Fatal("handlePlan returned empty content")
}
// The plan must be retrievable from memory.
got := a.loadPlan()
if got == "" {
t.Fatal("loadPlan() returned empty after handlePlan")
}
var decoded map[string]any
if err := json.Unmarshal([]byte(got), &decoded); err != nil {
t.Fatalf("stored plan is not valid JSON: %v", err)
}
if _, ok := decoded["steps"]; !ok {
t.Errorf("stored plan missing steps: %s", got)
}
}
func TestPlanShowsInPrompt(t *testing.T) {
mem := store.NewMemoryStore()
a := New(Name("planner"), Prompt("base prompt"), WithStore(mem)).(*agentImpl)
if got := a.buildPrompt(); got != "base prompt" {
t.Errorf("buildPrompt() with no plan = %q, want %q", got, "base prompt")
}
a.handlePlan(ai.ToolCall{Name: "plan", Input: map[string]any{"steps": []any{map[string]any{"task": "do it", "status": "pending"}}}})
got := a.buildPrompt()
if got == "base prompt" {
t.Error("buildPrompt() should include the plan once one is saved")
}
if !containsStr(got, "do it") {
t.Errorf("buildPrompt() = %q, should contain the saved plan", got)
}
}
func TestDiscoverToolsIncludesBuiltins(t *testing.T) {
reg := registry.NewMemoryRegistry()
a := New(Name("a"), WithRegistry(reg), WithStore(store.NewMemoryStore())).(*agentImpl)
a.setup()
tools, err := a.discoverTools()
if err != nil {
t.Fatalf("discoverTools: %v", err)
}
// No services registered, so the only tools should be the builtins.
if len(tools) != len(builtinTools()) {
t.Fatalf("discoverTools() = %d tools, want %d builtins", len(tools), len(builtinTools()))
}
}
func TestEphemeralAgentHasNoBuiltins(t *testing.T) {
reg := registry.NewMemoryRegistry()
a := New(Name("a.sub"), WithRegistry(reg), WithStore(store.NewMemoryStore())).(*agentImpl)
a.ephemeral = true
a.setup()
tools, err := a.discoverTools()
if err != nil {
t.Fatalf("discoverTools: %v", err)
}
if len(tools) != 0 {
t.Errorf("ephemeral agent discoverTools() = %d tools, want 0", len(tools))
}
}
func TestBuiltinsAccessor(t *testing.T) {
mem := store.NewMemoryStore()
tools, handle := Builtins(
Name("chat"),
WithStore(mem),
WithRegistry(registry.NewMemoryRegistry()),
)
if len(tools) != 3 {
t.Fatalf("Builtins() returned %d tools, want 3", len(tools))
}
// A name that isn't a built-in falls through (ok == false).
if _, _, ok := handle("not_a_builtin", nil); ok {
t.Error("handle(non-builtin) ok = true, want false")
}
// plan is handled and persisted under the configured name.
_, content, ok := handle(toolPlan, map[string]any{
"steps": []any{map[string]any{"task": "x", "status": "pending"}},
})
if !ok {
t.Fatal("handle(plan) ok = false, want true")
}
if content == "" {
t.Fatal("handle(plan) returned empty content")
}
scoped := store.Scope(mem, "agent", "chat")
if recs, err := scoped.Read(planKey); err != nil || len(recs) == 0 {
t.Errorf("plan not persisted in the agent's scoped store: err=%v recs=%d", err, len(recs))
}
}
func TestIsAgent(t *testing.T) {
reg := registry.NewMemoryRegistry()
// A plain service.
if err := reg.Register(&registry.Service{
Name: "task",
Nodes: []*registry.Node{{Id: "task-1", Address: "127.0.0.1:0"}},
}); err != nil {
t.Fatalf("register service: %v", err)
}
// An agent (advertises type=agent).
if err := reg.Register(&registry.Service{
Name: "task-mgr",
Metadata: map[string]string{"type": "agent"},
Nodes: []*registry.Node{{Id: "task-mgr-1", Address: "127.0.0.1:0"}},
}); err != nil {
t.Fatalf("register agent: %v", err)
}
a := New(Name("root"), WithRegistry(reg)).(*agentImpl)
if a.isAgent("task") {
t.Error("isAgent(task) = true, want false (plain service)")
}
if !a.isAgent("task-mgr") {
t.Error("isAgent(task-mgr) = false, want true (agent)")
}
if a.isAgent("nonexistent") {
t.Error("isAgent(nonexistent) = true, want false")
}
}
-286
View File
@@ -1,286 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/flow"
)
const (
agentAskStep = "ask"
agentApprovalStep = "approval"
agentInputStep = "input-required"
)
func (a *agentImpl) newCheckpointRun(runID, message, parentRunID string, existing *flow.Run) flow.Run {
now := time.Now()
run := flow.Run{
ID: runID,
ParentID: parentRunID,
Flow: a.opts.Name,
State: flow.State{Stage: agentAskStep, Data: []byte(message)},
Steps: []flow.StepRecord{{Name: agentAskStep, Status: "in_progress"}},
Status: "running",
Started: now,
Updated: now,
}
if existing != nil {
run = *existing
run.Status = "running"
run.State.Stage = agentAskStep
if len(run.Steps) == 0 {
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
}
run.Steps[0].Status = "in_progress"
run.Steps[0].Error = ""
run.Steps[0].Result = ""
}
return run
}
func (a *agentImpl) saveRun(ctx context.Context, run flow.Run) error {
if a.opts.Checkpoint == nil {
return nil
}
if err := a.opts.Checkpoint.Save(ctx, run); err != nil {
return fmt.Errorf("agent %s checkpoint save: %w", a.opts.Name, err)
}
if info, ok := ai.RunInfoFrom(ctx); ok {
a.recordTimelineEvent(ctx, RunEvent{
Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent,
Kind: "checkpoint", Name: run.State.Stage, Status: run.Status,
})
}
return nil
}
// Resume returns the response for a checkpointed agent run. Completed runs are
// returned from the checkpoint without calling the model or replaying tool
// calls; failed or in-progress runs continue from the saved input message.
func Resume(ctx context.Context, ag Agent, runID string) (*Response, error) {
a, ok := ag.(*agentImpl)
if !ok {
return nil, fmt.Errorf("agent resume: unsupported agent implementation %T", ag)
}
return a.resume(ctx, runID)
}
func (a *agentImpl) resume(ctx context.Context, runID string) (*Response, error) {
if a.opts.Checkpoint == nil {
return nil, fmt.Errorf("agent %s has no checkpoint configured", a.opts.Name)
}
run, ok, err := a.opts.Checkpoint.Load(ctx, runID)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("agent run %s not found", runID)
}
if run.Status == "paused" {
if run.State.Stage == agentInputStep {
return nil, fmt.Errorf("agent run %s is input-required; resume with ResumeInput", runID)
}
run.Status = "running"
run.State.Stage = agentAskStep
}
if run.Status == "done" {
var resp Response
if err := json.Unmarshal(run.State.Data, &resp); err != nil {
return nil, fmt.Errorf("agent run %s response decode: %w", runID, err)
}
return &resp, nil
}
if terminalAgentRunStatus(run.Status) {
return nil, fmt.Errorf("agent run %s is terminal with status %q", runID, run.Status)
}
message := string(run.State.Data)
parentID := run.ParentID
a.mu.Lock()
defer a.mu.Unlock()
if a.model == nil {
a.setup()
}
return a.askLocked(ctx, run.ID, message, parentID, &run, false)
}
// ResumeInput resumes a checkpointed agent run that paused via the built-in
// request_input tool. The supplied input is appended to the original request so
// the same run can continue with durable checkpoint and completed tool history.
func ResumeInput(ctx context.Context, ag Agent, runID, input string) (*Response, error) {
a, ok := ag.(*agentImpl)
if !ok {
return nil, fmt.Errorf("agent resume input: unsupported agent implementation %T", ag)
}
return a.resumeInput(ctx, runID, input)
}
func (a *agentImpl) resumeInput(ctx context.Context, runID, input string) (*Response, error) {
if a.opts.Checkpoint == nil {
return nil, fmt.Errorf("agent %s has no checkpoint configured", a.opts.Name)
}
run, ok, err := a.opts.Checkpoint.Load(ctx, runID)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("agent run %s not found", runID)
}
if run.Status != "paused" || run.State.Stage != agentInputStep {
return nil, fmt.Errorf("agent run %s is not waiting for human input", runID)
}
var p inputPause
if err := run.State.Scan(&p); err != nil {
return nil, fmt.Errorf("agent run %s input state decode: %w", runID, err)
}
message := p.OriginalMessage
if message == "" {
message = string(run.State.Data)
}
message += "\n\nHuman input: " + input
run.Status = "running"
run.State.Stage = agentAskStep
run.State.Data = []byte(message)
a.mu.Lock()
defer a.mu.Unlock()
if a.model == nil {
a.setup()
}
return a.askLocked(ctx, run.ID, message, run.ParentID, &run, true)
}
func (a *agentImpl) pending(ctx context.Context) ([]flow.Run, error) {
if a.opts.Checkpoint == nil {
return nil, nil
}
runs, err := a.opts.Checkpoint.List(ctx)
if err != nil {
return nil, err
}
out := runs[:0]
for _, run := range runs {
if run.Flow == a.opts.Name && !terminalAgentRunStatus(run.Status) {
out = append(out, run)
}
}
return out, nil
}
func terminalAgentRunStatus(status string) bool {
switch status {
case "done", "canceled", "timeout", "rate_limited", "expired":
return true
default:
return false
}
}
func agentRunFailureStatus(err error) string {
switch ai.ClassifyError(err) {
case ai.ErrorKindCanceled:
return "canceled"
case ai.ErrorKindTimeout:
return "timeout"
case ai.ErrorKindRateLimited:
return "rate_limited"
default:
return "failed"
}
}
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 {
return next(ctx, call)
}
name := toolCheckpointName(call)
if rec, ok := findStep(a.currentRun.Steps, name); ok && rec.Status == "done" {
return ai.ToolResult{ID: call.ID, Value: rec.Result, Content: rec.Result}
}
idx := upsertStep(&a.currentRun.Steps, flow.StepRecord{Name: name, Status: "in_progress"})
_ = a.saveRun(ctx, *a.currentRun)
res := next(ctx, call)
a.currentRun.Steps[idx].Attempts++
if res.Refused != "" {
a.currentRun.Steps[idx].Status = "failed"
a.currentRun.Steps[idx].Error = res.Content
_ = a.saveRun(ctx, *a.currentRun)
return res
}
a.currentRun.Steps[idx].Status = "done"
a.currentRun.Steps[idx].Result = res.Content
a.currentRun.Steps[idx].Error = ""
_ = a.saveRun(ctx, *a.currentRun)
return res
}
}
func toolCheckpointName(call ai.ToolCall) string {
b, _ := json.Marshal(call.Input)
return "tool:" + call.Name + ":" + string(b)
}
func findStep(steps []flow.StepRecord, name string) (flow.StepRecord, bool) {
for _, step := range steps {
if step.Name == name {
return step, true
}
}
return flow.StepRecord{}, false
}
func upsertStep(steps *[]flow.StepRecord, rec flow.StepRecord) int {
for i := range *steps {
if (*steps)[i].Name == rec.Name {
(*steps)[i].Status = rec.Status
(*steps)[i].Error = rec.Error
return i
}
}
if len(*steps) == 0 || (*steps)[0].Name != agentAskStep {
*steps = append([]flow.StepRecord{{Name: agentAskStep, Status: "in_progress"}}, (*steps)...)
}
*steps = append(*steps, rec)
return len(*steps) - 1
}
-572
View File
@@ -1,572 +0,0 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/client"
codecBytes "go-micro.dev/v6/codec/bytes"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
func TestResumeCompletedCheckpointDoesNotReplayModel(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "durable-agent")
calls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
return &ai.Response{Reply: "done"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("durable-agent"), WithCheckpoint(cp))
resp, err := a.Ask(ctx, "finish the work")
if err != nil {
t.Fatalf("Ask: %v", err)
}
if calls != 1 {
t.Fatalf("model calls after Ask = %d, want 1", calls)
}
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
t.Fatal("Resume of a completed run replayed the model")
return nil, nil
}
resumed, err := Resume(ctx, a, resp.RunID)
if err != nil {
t.Fatalf("Resume: %v", err)
}
if resumed.Reply != "done" {
t.Fatalf("resumed reply = %q, want done", resumed.Reply)
}
if resumed.RunID != resp.RunID {
t.Fatalf("resumed run id = %q, want %q", resumed.RunID, resp.RunID)
}
if calls != 1 {
t.Fatalf("model calls after Resume = %d, want 1", calls)
}
}
func TestResumeFailedCheckpointDoesNotReplayCompletedTool(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "tool-resume-agent")
toolRuns := 0
first := true
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler != nil {
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.charge", Input: map[string]any{"order": "42"}})
if res.Content != "charged" {
t.Fatalf("tool result = %q, want charged", res.Content)
}
}
if first {
first = false
return nil, errors.New("model connection dropped after tool")
}
return &ai.Response{Reply: "finished from checkpoint"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("tool-resume-agent"), WithCheckpoint(cp),
WithTool("external.charge", "charge once", nil, func(context.Context, map[string]any) (string, error) {
toolRuns++
return "charged", nil
}))
_, err := a.Ask(ctx, "charge order 42")
if err == nil {
t.Fatal("Ask succeeded, want simulated failure")
}
if toolRuns != 1 {
t.Fatalf("tool executions after failed Ask = %d, want 1", toolRuns)
}
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 {
t.Fatalf("Pending returned %d runs, want 1", len(runs))
}
resp, err := Resume(ctx, a, runs[0].ID)
if err != nil {
t.Fatalf("Resume: %v", err)
}
if resp.Reply != "finished from checkpoint" {
t.Fatalf("Resume reply = %q", resp.Reply)
}
if toolRuns != 1 {
t.Fatalf("tool executions after Resume = %d, want completed tool was not replayed", toolRuns)
}
}
func TestCheckpointSkipsDuplicateToolWithinAsk(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "tool-dedupe-agent")
toolRuns := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
t.Fatal("missing tool handler")
}
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"},
},
}})
for i := 0; i < 3; i++ {
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.create", Input: map[string]any{"title": "Design"}})
if res.Content != "created Design" {
t.Fatalf("tool result %d = %q, want cached created Design", i, res.Content)
}
}
return &ai.Response{Reply: "done"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("tool-dedupe-agent"), WithCheckpoint(cp),
WithTool("external.create", "create once", nil, func(context.Context, map[string]any) (string, error) {
toolRuns++
return "created Design", nil
}))
if _, err := a.Ask(ctx, "create Design once"); err != nil {
t.Fatalf("Ask: %v", err)
}
if toolRuns != 1 {
t.Fatalf("tool executions = %d, want duplicate calls within the run replayed from checkpoint", toolRuns)
}
if plan := a.loadPlan(); !strings.Contains(plan, `"status":"done"`) {
t.Fatalf("plan = %s, want completed action marked done", plan)
}
}
func TestCheckpointContinuesRunWithUnfinishedPlanStep(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "unfinished-plan-agent")
reg := registry.NewMemoryRegistry()
if err := reg.Register(&registry.Service{
Name: "comms",
Metadata: map[string]string{"type": "agent"},
Nodes: []*registry.Node{{Id: "comms-1", Address: "127.0.0.1:0"}},
}); err != nil {
t.Fatalf("register comms agent: %v", err)
}
delegateCalls := 0
fc := &fakeClient{Client: client.DefaultClient}
fc.callFn = func(ctx context.Context, req client.Request, rsp interface{}) error {
delegateCalls++
if req.Service() != "comms" || req.Endpoint() != "Agent.Chat" {
t.Fatalf("delegate RPC = %s %s, want comms Agent.Chat", req.Service(), req.Endpoint())
}
frame := rsp.(*codecBytes.Frame)
frame.Data = []byte(`{"reply":"owner notified","agent":"comms"}`)
return nil
}
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 launch tasks", "status": "done"},
map[string]any{"task": "delegate readiness notification to comms", "status": "in_progress"},
},
}})
return &ai.Response{Reply: "tasks are ready"}, nil
case 2:
if !strings.Contains(req.Prompt, "delegate readiness notification to comms") {
t.Fatalf("continuation prompt = %q, want unfinished step", req.Prompt)
}
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "delegate-1", Name: toolDelegate, Input: map[string]any{"task": "Notify owner@acme.com that the launch plan is ready", "to": "comms"}})
if !strings.Contains(res.Content, "owner notified") {
t.Fatalf("delegate result = %q, want owner notified", res.Content)
}
return &ai.Response{Reply: "all done"}, nil
default:
t.Fatalf("unexpected model call %d", modelCalls)
return nil, nil
}
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("unfinished-plan-agent"), WithCheckpoint(cp), WithRegistry(reg), WithClient(fc))
resp, err := a.Ask(ctx, "create tasks and notify owner")
if err != nil {
t.Fatalf("Ask: %v", err)
}
if resp.Reply != "all done" {
t.Fatalf("reply = %q, want final continuation reply", resp.Reply)
}
if modelCalls != 2 {
t.Fatalf("model calls = %d, want initial plus continuation", modelCalls)
}
if delegateCalls != 1 {
t.Fatalf("delegate calls = %d, want exactly one", delegateCalls)
}
if unfinished := a.unfinishedPlanSteps(); len(unfinished) != 0 {
t.Fatalf("unfinished plan steps = %v, want none", unfinished)
}
}
func TestResumeFailedCheckpointAfterFreshAgentRestart(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "restart-resume-agent")
toolRuns := 0
modelCalls := 0
failFirst := true
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
modelCalls++
if opts.ToolHandler != nil {
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.provision", Input: map[string]any{"service": "api"}})
if res.Content != "provisioned" {
t.Fatalf("tool result = %q, want provisioned", res.Content)
}
}
if failFirst {
failFirst = false
return nil, errors.New("process stopped after tool checkpoint")
}
return &ai.Response{Reply: "resumed after restart"}, nil
}
defer func() { fakeGen = nil }()
newAgent := func() *agentImpl {
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
}))
}
first := newAgent()
_, err := first.Ask(ctx, "provision api")
if err == nil {
t.Fatal("Ask succeeded, want simulated process stop")
}
if toolRuns != 1 {
t.Fatalf("tool executions after failed Ask = %d, want 1", toolRuns)
}
runs, err := Pending(ctx, first)
if err != nil {
t.Fatalf("Pending before restart: %v", err)
}
if len(runs) != 1 {
t.Fatalf("Pending before restart returned %d runs, want 1", len(runs))
}
restarted := newAgent()
resp, err := Resume(ctx, restarted, runs[0].ID)
if err != nil {
t.Fatalf("Resume after restart: %v", err)
}
if resp.Reply != "resumed after restart" || resp.RunID != runs[0].ID {
t.Fatalf("response = %#v, want resumed reply on original run id", resp)
}
if toolRuns != 1 {
t.Fatalf("tool executions after restart resume = %d, want checkpointed tool not replayed", toolRuns)
}
if modelCalls != 2 {
t.Fatalf("model calls = %d, want initial call plus resumed call", modelCalls)
}
loaded, ok, err := cp.Load(ctx, runs[0].ID)
if err != nil || !ok {
t.Fatalf("Load resumed run ok=%v err=%v", ok, err)
}
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)
}
}
func TestResumeFailedCheckpointDoesNotDuplicateCompactedMemory(t *testing.T) {
ctx := context.Background()
st := store.NewMemoryStore()
cp := flow.StoreCheckpoint(st, "memory-resume-agent")
failRetry := true
var sawRecall bool
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
for _, msg := range req.Messages {
if text, ok := msg.Content.(string); ok && strings.Contains(text, "alpha code is 42") {
sawRecall = true
}
}
if strings.Contains(req.Prompt, "use alpha code") && failRetry {
failRetry = false
return nil, errors.New("model connection dropped")
}
return &ai.Response{Reply: "ok"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("memory-resume-agent"), WithStore(st), WithCheckpoint(cp), CompactMemory(4, 1), MemoryRecallLimit(2))
for _, msg := range []string{"alpha code is 42", "beta note", "gamma note"} {
if _, err := a.Ask(ctx, msg); err != nil {
t.Fatalf("Ask(%q): %v", msg, err)
}
}
_, err := a.Ask(ctx, "use alpha code now")
if err == nil {
t.Fatal("Ask succeeded, want simulated provider failure")
}
if got := countMemoryContent(a.mem.Messages(), "use alpha code now"); got != 1 {
t.Fatalf("failed Ask stored prompt %d times, want 1", got)
}
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 {
t.Fatalf("Pending returned %d runs, want 1", len(runs))
}
if _, err := Resume(ctx, a, runs[0].ID); err != nil {
t.Fatalf("Resume: %v", err)
}
if got := countMemoryContent(a.mem.Messages(), "use alpha code now"); got != 1 {
t.Fatalf("resumed failed Ask stored prompt %d times, want no duplicate", got)
}
if !sawRecall {
t.Fatal("resume did not retrieve archived compacted memory")
}
if got := len(a.mem.Messages()); got > 4 {
t.Fatalf("compacted memory retained %d messages after resume, want <= 4", got)
}
}
func countMemoryContent(messages []ai.Message, needle string) int {
var count int
for _, msg := range messages {
if text, ok := msg.Content.(string); ok && strings.Contains(text, needle) {
count++
}
}
return count
}
func TestPendingReturnsUnfinishedAgentRuns(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "pending-agent")
run := flow.Run{ID: "run-1", Flow: "pending-agent", Status: "failed", State: flow.State{Stage: agentAskStep, Data: []byte("retry me")}}
if err := cp.Save(ctx, run); err != nil {
t.Fatalf("Save: %v", err)
}
a := newTestAgent(Name("pending-agent"), WithCheckpoint(cp))
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 || runs[0].ID != "run-1" {
t.Fatalf("Pending = %#v, want run-1", runs)
}
}
func TestPendingSkipsTerminalCanceledAndExpiredAgentRuns(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "terminal-agent")
for _, run := range []flow.Run{
{ID: "active", Flow: "terminal-agent", Status: "failed", State: flow.State{Stage: agentAskStep, Data: []byte("retry me")}},
{ID: "done", Flow: "terminal-agent", Status: "done", State: flow.State{Stage: agentAskStep, Data: []byte("done")}},
{ID: "canceled", Flow: "terminal-agent", Status: "canceled", State: flow.State{Stage: agentAskStep, Data: []byte("canceled")}},
{ID: "expired", Flow: "terminal-agent", Status: "expired", State: flow.State{Stage: agentAskStep, Data: []byte("expired")}},
} {
if err := cp.Save(ctx, run); err != nil {
t.Fatalf("Save(%s): %v", run.ID, err)
}
}
a := newTestAgent(Name("terminal-agent"), WithCheckpoint(cp))
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 || runs[0].ID != "active" {
t.Fatalf("Pending = %#v, want only active failed run", runs)
}
for _, id := range []string{"canceled", "expired"} {
if _, err := Resume(ctx, a, id); err == nil || !strings.Contains(err.Error(), "terminal") {
t.Fatalf("Resume(%s) err = %v, want terminal status error", id, err)
}
}
}
func TestHumanInputPauseResumesSameRunWithInput(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "input-agent")
calls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
if calls == 1 {
if opts.ToolHandler != nil {
opts.ToolHandler(ctx, ai.ToolCall{ID: "input-1", Name: toolHumanInput, Input: map[string]any{"prompt": "Which region should I deploy to?"}})
}
return &ai.Response{Reply: "waiting"}, nil
}
if !strings.Contains(req.Prompt, "Human input: us-east-1") {
t.Fatalf("resumed prompt = %q, want human input", req.Prompt)
}
return &ai.Response{Reply: "deploying to us-east-1"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("input-agent"), WithCheckpoint(cp))
_, err := a.Ask(ctx, "deploy the service")
if err == nil {
t.Fatal("Ask succeeded, want input-required pause")
}
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 || runs[0].Status != "paused" || runs[0].State.Stage != agentInputStep {
t.Fatalf("paused runs = %#v, want one input-required run", runs)
}
var pause inputPause
if err := runs[0].State.Scan(&pause); err != nil {
t.Fatalf("Scan pause: %v", err)
}
if pause.OriginalMessage != "deploy the service" || pause.Prompt != "Which region should I deploy to?" {
t.Fatalf("pause = %#v", pause)
}
if _, err := Resume(ctx, a, runs[0].ID); err == nil || !strings.Contains(err.Error(), "ResumeInput") {
t.Fatalf("Resume input-required err = %v, want guidance", err)
}
resp, err := ResumeInput(ctx, a, runs[0].ID, "us-east-1")
if err != nil {
t.Fatalf("ResumeInput: %v", err)
}
if resp.RunID != runs[0].ID || resp.Reply != "deploying to us-east-1" {
t.Fatalf("response = %#v", resp)
}
loaded, ok, err := cp.Load(ctx, runs[0].ID)
if err != nil || !ok {
t.Fatalf("Load resumed run ok=%v err=%v", ok, err)
}
if loaded.Status != "done" {
t.Fatalf("resumed run status = %q, want done", loaded.Status)
}
}
func TestHumanInputResumeHonorsCanceledContextAndLeavesRunPending(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "input-cancel-agent")
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler != nil {
opts.ToolHandler(ctx, ai.ToolCall{ID: "input-1", Name: toolHumanInput, Input: map[string]any{"prompt": "Approve deploy?"}})
}
return &ai.Response{Reply: "waiting"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("input-cancel-agent"), WithCheckpoint(cp))
if _, err := a.Ask(ctx, "deploy the service"); err == nil {
t.Fatal("Ask succeeded, want input-required pause")
}
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 {
t.Fatalf("Pending returned %d runs, want 1: %#v", len(runs), runs)
}
canceled, cancel := context.WithCancel(ctx)
cancel()
if _, err := ResumeInput(canceled, a, runs[0].ID, "yes"); !errors.Is(err, context.Canceled) {
t.Fatalf("ResumeInput canceled err = %v, want context.Canceled", err)
}
loaded, ok, err := cp.Load(ctx, runs[0].ID)
if err != nil || !ok {
t.Fatalf("Load paused run ok=%v err=%v", ok, err)
}
if loaded.Status != "paused" || loaded.State.Stage != agentInputStep {
t.Fatalf("run status/stage after canceled resume = %s/%s, want paused/%s", loaded.Status, loaded.State.Stage, agentInputStep)
}
var pause inputPause
if err := loaded.State.Scan(&pause); err != nil {
t.Fatalf("Scan pause after canceled resume: %v", err)
}
if pause.OriginalMessage != "deploy the service" || pause.Prompt != "Approve deploy?" {
t.Fatalf("pause after canceled resume = %#v", pause)
}
}
func TestApprovalDenialPausesCheckpointedRunAndResumeContinues(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "approval-agent")
calls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
if opts.ToolHandler != nil {
opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.approve", Input: map[string]any{"id": "42"}})
}
return &ai.Response{Reply: "model saw approval result"}, nil
}
defer func() { fakeGen = nil }()
approved := false
a := newTestAgent(Name("approval-agent"), WithCheckpoint(cp),
WithTool("external.approve", "guarded external action", nil, func(context.Context, map[string]any) (string, error) { return "ok", nil }),
ApproveTool(func(tool string, input map[string]any) (bool, string) {
return approved, "waiting for operator"
}))
_, err := a.Ask(ctx, "send the guarded update")
if err == nil {
t.Fatal("Ask succeeded, want paused approval error")
}
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 {
t.Fatalf("Pending returned %d runs, want 1: %#v", len(runs), runs)
}
if runs[0].Status != "paused" || runs[0].State.Stage != agentApprovalStep {
t.Fatalf("run status/stage = %s/%s, want paused/%s", runs[0].Status, runs[0].State.Stage, agentApprovalStep)
}
if got := string(runs[0].State.Data); got != "send the guarded update" {
t.Fatalf("paused run data = %q", got)
}
approved = true
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
if opts.ToolHandler != nil {
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-2", Name: "external.approve", Input: map[string]any{"id": "42"}})
if res.Refused != "" {
t.Fatalf("resumed call was refused: %#v", res)
}
}
return &ai.Response{Reply: "done after approval"}, nil
}
resp, err := Resume(ctx, a, runs[0].ID)
if err != nil {
t.Fatalf("Resume: %v", err)
}
if resp.Reply != "done after approval" {
t.Fatalf("Resume reply = %q", resp.Reply)
}
loaded, ok, err := cp.Load(ctx, runs[0].ID)
if err != nil || !ok {
t.Fatalf("Load resumed run ok=%v err=%v", ok, err)
}
if loaded.Status != "done" {
t.Fatalf("resumed run status = %q, want done", loaded.Status)
}
if calls != 2 {
t.Fatalf("model calls = %d, want 2", calls)
}
}
+54
View File
@@ -0,0 +1,54 @@
// Package command is an interface for defining bot commands
package command
var (
// Commmands keyed by golang/regexp patterns
// regexp.Match(key, input) is used to match
Commands = map[string]Command{}
)
// Command is the interface for specific named
// commands executed via plugins or the bot.
type Command interface {
// Executes the command with args passed in
Exec(args ...string) ([]byte, error)
// Usage of the command
Usage() string
// Description of the command
Description() string
// Name of the command
String() string
}
type cmd struct {
name string
usage string
description string
exec func(args ...string) ([]byte, error)
}
func (c *cmd) Description() string {
return c.description
}
func (c *cmd) Exec(args ...string) ([]byte, error) {
return c.exec(args...)
}
func (c *cmd) Usage() string {
return c.usage
}
func (c *cmd) String() string {
return c.name
}
// NewCommand helps quickly create a new command
func NewCommand(name, usage, description string, exec func(args ...string) ([]byte, error)) Command {
return &cmd{
name: name,
usage: usage,
description: description,
exec: exec,
}
}
+65
View File
@@ -0,0 +1,65 @@
package command
import (
"testing"
)
func TestCommand(t *testing.T) {
c := &cmd{
name: "test",
usage: "test usage",
description: "test description",
exec: func(args ...string) ([]byte, error) {
return []byte("test"), nil
},
}
if c.String() != c.name {
t.Fatalf("expected name %s got %s", c.name, c.String())
}
if c.Usage() != c.usage {
t.Fatalf("expected usage %s got %s", c.usage, c.Usage())
}
if c.Description() != c.description {
t.Fatalf("expected description %s got %s", c.description, c.Description())
}
if r, err := c.Exec(); err != nil {
t.Fatal(err)
} else if string(r) != "test" {
t.Fatalf("expected exec result test got %s", string(r))
}
}
func TestNewCommand(t *testing.T) {
c := &cmd{
name: "test",
usage: "test usage",
description: "test description",
exec: func(args ...string) ([]byte, error) {
return []byte("test"), nil
},
}
nc := NewCommand(c.name, c.usage, c.description, c.exec)
if nc.String() != c.name {
t.Fatalf("expected name %s got %s", c.name, nc.String())
}
if nc.Usage() != c.usage {
t.Fatalf("expected usage %s got %s", c.usage, nc.Usage())
}
if nc.Description() != c.description {
t.Fatalf("expected description %s got %s", c.description, nc.Description())
}
if r, err := nc.Exec(); err != nil {
t.Fatal(err)
} else if string(r) != "test" {
t.Fatalf("expected exec result test got %s", string(r))
}
}
-338
View File
@@ -1,338 +0,0 @@
package agent
import (
"context"
"errors"
"fmt"
"os"
"strings"
"testing"
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
type conformanceProvider struct {
name string
model string
key string
live bool
}
func TestAgentProviderConformanceMatrix(t *testing.T) {
providers := []conformanceProvider{
{name: "fake"},
{name: "openai", key: "OPENAI_API_KEY", model: "GO_MICRO_CONFORMANCE_OPENAI_MODEL", live: true},
{name: "anthropic", key: "ANTHROPIC_API_KEY", model: "GO_MICRO_CONFORMANCE_ANTHROPIC_MODEL", live: true},
{name: "atlascloud", key: "ATLASCLOUD_API_KEY", model: "GO_MICRO_CONFORMANCE_ATLASCLOUD_MODEL", live: true},
{name: "gemini", key: "GEMINI_API_KEY", model: "GO_MICRO_CONFORMANCE_GEMINI_MODEL", live: true},
{name: "groq", key: "GROQ_API_KEY", model: "GO_MICRO_CONFORMANCE_GROQ_MODEL", live: true},
{name: "mistral", key: "MISTRAL_API_KEY", model: "GO_MICRO_CONFORMANCE_MISTRAL_MODEL", live: true},
{name: "together", key: "TOGETHER_API_KEY", model: "GO_MICRO_CONFORMANCE_TOGETHER_MODEL", live: true},
}
selected := selectedConformanceProviders(os.Getenv("GO_MICRO_AGENT_CONFORMANCE_PROVIDERS"))
for _, provider := range providers {
provider := provider
if len(selected) > 0 && !selected[provider.name] {
continue
}
t.Run(provider.name, func(t *testing.T) {
runAgentConformanceScenario(t, provider)
})
}
}
func selectedConformanceProviders(csv string) map[string]bool {
out := map[string]bool{}
for _, part := range strings.Split(csv, ",") {
part = strings.TrimSpace(part)
if part != "" {
out[part] = true
}
}
return out
}
func runAgentConformanceScenario(t *testing.T, provider conformanceProvider) {
t.Helper()
if provider.live {
if os.Getenv(provider.key) == "" {
t.Skipf("%s not set; skipping live %s conformance", provider.key, provider.name)
}
if os.Getenv("GO_MICRO_AGENT_CONFORMANCE_LIVE") == "" {
t.Skipf("GO_MICRO_AGENT_CONFORMANCE_LIVE not set; skipping live %s conformance", provider.name)
}
} 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
}
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{
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 == "" {
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},
},
}, nil
}
defer func() { fakeGen = nil }()
}
var sawTool bool
var sawRunInfo bool
var sawBlockedDelegate bool
agentOpts := []Option{
Name("conformance-" + provider.name),
Provider(provider.name),
APIKey(os.Getenv(provider.key)),
Prompt("You are a conformance test agent. Create a short plan, use conformance_echo exactly once with input {\"value\":\"agent-conformance\"}, then attempt to delegate a summary to blocked-reviewer. If the delegate is refused, explain the refusal and answer with the echo 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) {
sawTool = true
info, ok := ai.RunInfoFrom(ctx)
if !ok {
return "", errors.New("missing run info")
}
if info.RunID == "" || info.Agent != "conformance-"+provider.name {
return "", fmt.Errorf("unexpected run info: %+v", info)
}
sawRunInfo = true
if input["value"] != "agent-conformance" {
return "", fmt.Errorf("unexpected value %v", input["value"])
}
return `{"marker":"agent-conformance-ok"}`, nil
}),
}
if provider.model != "" {
if model := os.Getenv(provider.model); model != "" {
agentOpts = append(agentOpts, Model(model))
}
}
a := New(agentOpts...)
resp, err := askWithConformanceToolRetry(context.Background(), a, "Run the provider conformance check.", &sawTool)
if err != nil {
t.Fatalf("Ask: %v", err)
}
if resp.RunID == "" {
t.Fatal("RunID is empty")
}
if resp.Agent != "conformance-"+provider.name {
t.Fatalf("Agent = %q", resp.Agent)
}
if !sawTool {
t.Fatal("provider did not request the conformance tool")
}
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 askWithConformanceToolRetry(ctx context.Context, a Agent, initialPrompt string, sawTool *bool) (*Response, error) {
const maxAttempts = 2
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
}
if sawTool != nil && *sawTool {
return resp, nil
}
if attempt == maxAttempts {
break
}
prompt = "The previous response did not call the required conformance_echo tool. Retry the same conformance check now: you must call conformance_echo exactly once with input {\"value\":\"agent-conformance\"} before any final answer, then include the tool result marker in the final answer."
}
return resp, nil
}
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")
}
defer func() { fakeGen = nil }()
a := New(
Name("conformance-error"),
Provider("fake"),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WithMemory(NewInMemory(4)),
)
_, err := a.Ask(context.Background(), "fail deterministically")
if err == nil || !strings.Contains(err.Error(), "conformance provider failure") {
t.Fatalf("Ask error = %v, want conformance provider failure", err)
}
}
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 TestAgentExecutesProviderTextToolCallFallback(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")
}
return &ai.Response{
Reply: `{"name":"conformance_echo","input":{"value":"agent-conformance"}}`,
}, nil
}
defer func() { fakeGen = nil }()
var sawTool bool
a := New(
Name("conformance-text-tool"),
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
if input["value"] != "agent-conformance" {
return "", fmt.Errorf("unexpected value %v", input["value"])
}
return `{"marker":"agent-conformance-ok"}`, nil
}),
)
resp, err := a.Ask(context.Background(), "Run the text tool call fallback.")
if err != nil {
t.Fatalf("Ask: %v", err)
}
if !sawTool {
t.Fatal("text tool call fallback did not execute the tool")
}
if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Name != "conformance_echo" {
t.Fatalf("ToolCalls = %+v, want conformance_echo", resp.ToolCalls)
}
if !strings.Contains(resp.Reply, "agent-conformance-ok") {
t.Fatalf("Reply = %q, want tool result marker", resp.Reply)
}
if strings.Contains(resp.Reply, `"name":"conformance_echo"`) {
t.Fatalf("Reply = %q, want tool result instead of raw JSON", resp.Reply)
}
}
-92
View File
@@ -1,92 +0,0 @@
package agent
import (
"context"
"strings"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
// toolContent runs a tool call through a handler and returns the content
// shown to the model — the part these tests assert on.
func toolContent(h ai.ToolHandler, name string, input map[string]any) string {
return h(context.Background(), ai.ToolCall{Name: name, Input: input}).Content
}
// MaxSteps refuses tool calls once the per-Ask limit is exceeded; plan
// is bookkeeping and is never counted.
func TestMaxStepsStopsActions(t *testing.T) {
a := newTestAgent(Name("limited"), MaxSteps(2))
h := a.toolHandler()
// plan must not consume a step.
a.steps = 0
toolContent(h, toolPlan, map[string]any{"steps": []any{}})
if a.steps != 0 {
t.Fatalf("plan consumed a step: steps=%d", a.steps)
}
// First two actions are allowed (they fall through to RPC, which
// fails harmlessly — we only care they weren't refused by the limit).
for i := 1; i <= 2; i++ {
content := toolContent(h, "demo_Svc_Do", map[string]any{})
if strings.Contains(content, "step limit") {
t.Fatalf("action %d wrongly hit the step limit", i)
}
}
// Third action exceeds MaxSteps(2) and must be refused.
content := toolContent(h, "demo_Svc_Do", map[string]any{})
if !strings.Contains(content, "step limit") {
t.Errorf("third action should hit the step limit; got %q", content)
}
}
// ApproveTool blocks an action when the hook denies it, and the denial
// reason is surfaced to the model.
func TestApproveToolBlocks(t *testing.T) {
var sawTool string
a := newTestAgent(Name("gated"),
ApproveTool(func(tool string, input map[string]any) (bool, string) {
sawTool = tool
return false, "needs sign-off"
}),
)
content := toolContent(a.toolHandler(), "demo_Svc_Do", map[string]any{})
if sawTool != "demo_Svc_Do" {
t.Errorf("approver saw %q, want demo_Svc_Do", sawTool)
}
if !strings.Contains(content, "not approved") || !strings.Contains(content, "needs sign-off") {
t.Errorf("blocked call should surface the reason; got %q", content)
}
}
// A denying approver must not gate the internal plan tool.
func TestApproveToolDoesNotGatePlan(t *testing.T) {
mem := store.NewMemoryStore()
a := New(
Name("gated"),
Provider("fake"),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(mem),
ApproveTool(func(tool string, input map[string]any) (bool, string) {
return false, "deny everything"
}),
).(*agentImpl)
a.setup()
content := toolContent(a.toolHandler(), toolPlan, map[string]any{
"steps": []any{map[string]any{"task": "x", "status": "pending"}},
})
if strings.Contains(content, "not approved") {
t.Errorf("plan must not be gated by ApproveTool; got %q", content)
}
if recs, _ := store.Scope(mem, "agent", "gated").Read(planKey); len(recs) == 0 {
t.Error("plan should have been persisted despite the denying approver")
}
}
+22
View File
@@ -0,0 +1,22 @@
# Discord input for micro-bot
[Discord](https://discordapp.com) support for micro bot based on [discordgo](github.com/bwmarrin/discordgo).
This was originally written by Aleksandr Tihomirov (@zet4) and can be found at https://github.com/zet4/micro-misc/.
## Options
### discord_token
You have to supply an application token via `--discord_token`.
Head over to Discord's [developer introduction](https://discordapp.com/developers/docs/intro)
to learn how to create applications and how the API works.
### discord_prefix
Set a command prefix with `--discord_prefix`. The default prefix is `Micro `.
You can mention the bot or use the prefix to run a command.
### discord_whitelist
Pass a list of comma-separated user IDs with `--discord_whitelist`. Only allow
these users to use the bot.
+94
View File
@@ -0,0 +1,94 @@
package discord
import (
"errors"
"strings"
"sync"
"github.com/bwmarrin/discordgo"
"github.com/micro/go-micro/agent/input"
"github.com/micro/go-micro/util/log"
)
type discordConn struct {
master *discordInput
exit chan struct{}
recv chan *discordgo.Message
sync.Mutex
}
func newConn(master *discordInput) *discordConn {
conn := &discordConn{
master: master,
exit: make(chan struct{}),
recv: make(chan *discordgo.Message),
}
conn.master.session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) {
if m.Author.ID == master.botID {
return
}
whitelisted := false
for _, ID := range conn.master.whitelist {
if m.Author.ID == ID {
whitelisted = true
break
}
}
if len(master.whitelist) > 0 && !whitelisted {
return
}
var valid bool
m.Message.Content, valid = conn.master.prefixfn(m.Message.Content)
if !valid {
return
}
conn.recv <- m.Message
})
return conn
}
func (dc *discordConn) Recv(event *input.Event) error {
for {
select {
case <-dc.exit:
return errors.New("connection closed")
case msg := <-dc.recv:
event.From = msg.ChannelID + ":" + msg.Author.ID
event.To = dc.master.botID
event.Type = input.TextEvent
event.Data = []byte(msg.Content)
return nil
}
}
}
func (dc *discordConn) Send(e *input.Event) error {
fields := strings.Split(e.To, ":")
_, err := dc.master.session.ChannelMessageSend(fields[0], string(e.Data))
if err != nil {
log.Log("[bot][loop][send]", err)
}
return nil
}
func (dc *discordConn) Close() error {
if err := dc.master.session.Close(); err != nil {
return err
}
select {
case <-dc.exit:
return nil
default:
close(dc.exit)
}
return nil
}
+153
View File
@@ -0,0 +1,153 @@
package discord
import (
"fmt"
"sync"
"errors"
"strings"
"github.com/bwmarrin/discordgo"
"github.com/micro/cli"
"github.com/micro/go-micro/agent/input"
)
func init() {
input.Inputs["discord"] = newInput()
}
func newInput() *discordInput {
return &discordInput{}
}
type discordInput struct {
token string
whitelist []string
prefix string
prefixfn func(string) (string, bool)
botID string
session *discordgo.Session
sync.Mutex
running bool
exit chan struct{}
}
func (d *discordInput) Flags() []cli.Flag {
return []cli.Flag{
cli.StringFlag{
Name: "discord_token",
EnvVar: "MICRO_DISCORD_TOKEN",
Usage: "Discord token (prefix with Bot if it's for bot account)",
},
cli.StringFlag{
Name: "discord_whitelist",
EnvVar: "MICRO_DISCORD_WHITELIST",
Usage: "Discord Whitelist (seperated by ,)",
},
cli.StringFlag{
Name: "discord_prefix",
Usage: "Discord Prefix",
EnvVar: "MICRO_DISCORD_PREFIX",
Value: "Micro ",
},
}
}
func (d *discordInput) Init(ctx *cli.Context) error {
token := ctx.String("discord_token")
whitelist := ctx.String("discord_whitelist")
prefix := ctx.String("discord_prefix")
if len(token) == 0 {
return errors.New("require token")
}
d.token = token
d.prefix = prefix
if len(whitelist) > 0 {
d.whitelist = strings.Split(whitelist, ",")
}
return nil
}
func (d *discordInput) Start() error {
if len(d.token) == 0 {
return errors.New("missing discord configuration")
}
d.Lock()
defer d.Unlock()
if d.running {
return nil
}
var err error
d.session, err = discordgo.New(d.token)
if err != nil {
return err
}
u, err := d.session.User("@me")
if err != nil {
return err
}
d.botID = u.ID
d.prefixfn = CheckPrefixFactory(fmt.Sprintf("<@%s> ", d.botID), fmt.Sprintf("<@!%s> ", d.botID), d.prefix)
d.exit = make(chan struct{})
d.running = true
return nil
}
func (d *discordInput) Stream() (input.Conn, error) {
d.Lock()
defer d.Unlock()
if !d.running {
return nil, errors.New("not running")
}
//Fire-n-forget close just in case...
d.session.Close()
conn := newConn(d)
if err := d.session.Open(); err != nil {
return nil, err
}
return conn, nil
}
func (d *discordInput) Stop() error {
d.Lock()
defer d.Unlock()
if !d.running {
return nil
}
close(d.exit)
d.running = false
return nil
}
func (d *discordInput) String() string {
return "discord"
}
// CheckPrefixFactory Creates a prefix checking function and stuff.
func CheckPrefixFactory(prefixes ...string) func(string) (string, bool) {
return func(content string) (string, bool) {
for _, prefix := range prefixes {
if strings.HasPrefix(content, prefix) {
return strings.TrimPrefix(content, prefix), true
}
}
return "", false
}
}
+55
View File
@@ -0,0 +1,55 @@
// Package input is an interface for bot inputs
package input
import (
"github.com/micro/cli"
)
type EventType string
const (
TextEvent EventType = "text"
)
var (
// Inputs keyed by name
// Example slack or hipchat
Inputs = map[string]Input{}
)
// Event is the unit sent and received
type Event struct {
Type EventType
From string
To string
Data []byte
Meta map[string]interface{}
}
// Input is an interface for sources which
// provide a way to communicate with the bot.
// Slack, HipChat, XMPP, etc.
type Input interface {
// Provide cli flags
Flags() []cli.Flag
// Initialise input using cli context
Init(*cli.Context) error
// Stream events from the input
Stream() (Conn, error)
// Start the input
Start() error
// Stop the input
Stop() error
// name of the input
String() string
}
// Conn interface provides a way to
// send and receive events. Send and
// Recv both block until succeeding
// or failing.
type Conn interface {
Close() error
Recv(*Event) error
Send(*Event) error
}
+160
View File
@@ -0,0 +1,160 @@
package slack
import (
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/micro/go-micro/agent/input"
"github.com/nlopes/slack"
)
// Satisfies the input.Conn interface
type slackConn struct {
auth *slack.AuthTestResponse
rtm *slack.RTM
exit chan bool
sync.Mutex
names map[string]string
}
func (s *slackConn) run() {
// func retrieves user names and maps to IDs
setNames := func() {
names := make(map[string]string)
users, err := s.rtm.Client.GetUsers()
if err != nil {
return
}
for _, user := range users {
names[user.ID] = user.Name
}
s.Lock()
s.names = names
s.Unlock()
}
setNames()
t := time.NewTicker(time.Minute)
defer t.Stop()
for {
select {
case <-s.exit:
return
case <-t.C:
setNames()
}
}
}
func (s *slackConn) getName(id string) string {
s.Lock()
name := s.names[id]
s.Unlock()
return name
}
func (s *slackConn) Close() error {
select {
case <-s.exit:
return nil
default:
close(s.exit)
}
return nil
}
func (s *slackConn) Recv(event *input.Event) error {
if event == nil {
return errors.New("event cannot be nil")
}
for {
select {
case <-s.exit:
return errors.New("connection closed")
case e := <-s.rtm.IncomingEvents:
switch ev := e.Data.(type) {
case *slack.MessageEvent:
// only accept type message
if ev.Type != "message" {
continue
}
// only accept DMs or messages to me
switch {
case strings.HasPrefix(ev.Channel, "D"):
case strings.HasPrefix(ev.Text, s.auth.User):
case strings.HasPrefix(ev.Text, fmt.Sprintf("<@%s>", s.auth.UserID)):
default:
continue
}
// Strip username from text
switch {
case strings.HasPrefix(ev.Text, s.auth.User):
args := strings.Split(ev.Text, " ")[1:]
ev.Text = strings.Join(args, " ")
event.To = s.auth.User
case strings.HasPrefix(ev.Text, fmt.Sprintf("<@%s>", s.auth.UserID)):
args := strings.Split(ev.Text, " ")[1:]
ev.Text = strings.Join(args, " ")
event.To = s.auth.UserID
}
if event.Meta == nil {
event.Meta = make(map[string]interface{})
}
// fill in the blanks
event.From = ev.Channel + ":" + ev.User
event.Type = input.TextEvent
event.Data = []byte(ev.Text)
event.Meta["reply"] = ev
return nil
case *slack.InvalidAuthEvent:
return errors.New("invalid credentials")
}
}
}
}
func (s *slackConn) Send(event *input.Event) error {
var channel, message, name string
if len(event.To) == 0 {
return errors.New("require Event.To")
}
parts := strings.Split(event.To, ":")
if len(parts) == 2 {
channel = parts[0]
name = s.getName(parts[1])
// try using reply meta
} else if ev, ok := event.Meta["reply"]; ok {
channel = ev.(*slack.MessageEvent).Channel
name = s.getName(ev.(*slack.MessageEvent).User)
}
// don't know where to send the message
if len(channel) == 0 {
return errors.New("could not determine who message is to")
}
if len(name) == 0 || strings.HasPrefix(channel, "D") {
message = string(event.Data)
} else {
message = fmt.Sprintf("@%s: %s", name, string(event.Data))
}
s.rtm.SendMessage(s.rtm.NewOutgoingMessage(message, channel))
return nil
}
+147
View File
@@ -0,0 +1,147 @@
package slack
import (
"errors"
"sync"
"github.com/micro/cli"
"github.com/micro/go-micro/agent/input"
"github.com/nlopes/slack"
)
type slackInput struct {
debug bool
token string
sync.Mutex
running bool
exit chan bool
api *slack.Client
}
func init() {
input.Inputs["slack"] = NewInput()
}
func (p *slackInput) Flags() []cli.Flag {
return []cli.Flag{
cli.BoolFlag{
Name: "slack_debug",
Usage: "Slack debug output",
EnvVar: "MICRO_SLACK_DEBUG",
},
cli.StringFlag{
Name: "slack_token",
Usage: "Slack token",
EnvVar: "MICRO_SLACK_TOKEN",
},
}
}
func (p *slackInput) Init(ctx *cli.Context) error {
debug := ctx.Bool("slack_debug")
token := ctx.String("slack_token")
if len(token) == 0 {
return errors.New("missing slack token")
}
p.debug = debug
p.token = token
return nil
}
func (p *slackInput) Stream() (input.Conn, error) {
p.Lock()
defer p.Unlock()
if !p.running {
return nil, errors.New("not running")
}
// test auth
auth, err := p.api.AuthTest()
if err != nil {
return nil, err
}
rtm := p.api.NewRTM()
exit := make(chan bool)
go rtm.ManageConnection()
go func() {
select {
case <-p.exit:
select {
case <-exit:
return
default:
close(exit)
}
case <-exit:
}
rtm.Disconnect()
}()
conn := &slackConn{
auth: auth,
rtm: rtm,
exit: exit,
names: make(map[string]string),
}
go conn.run()
return conn, nil
}
func (p *slackInput) Start() error {
if len(p.token) == 0 {
return errors.New("missing slack token")
}
p.Lock()
defer p.Unlock()
if p.running {
return nil
}
api := slack.New(p.token, slack.OptionDebug(p.debug))
// test auth
_, err := api.AuthTest()
if err != nil {
return err
}
p.api = api
p.exit = make(chan bool)
p.running = true
return nil
}
func (p *slackInput) Stop() error {
p.Lock()
defer p.Unlock()
if !p.running {
return nil
}
close(p.exit)
p.running = false
return nil
}
func (p *slackInput) String() string {
return "slack"
}
func NewInput() input.Input {
return &slackInput{}
}
+18
View File
@@ -0,0 +1,18 @@
# Telegram Messenger input for micro bot
[Telegram](https://telegram.org) support for micro bot based on [telegram-bot-api](https://github.com/go-telegram-bot-api/telegram-bot-api).
## Options
### --telegram_token (required)
Sets bot's token for interacting with API.
Head over to Telegram's [API documentation](https://core.telegram.org/bots/api)
to learn how to create bots and how the API works.
### --telegram_debug
Sets the debug flag to make the bot's output verbose.
### --telegram_whitelist
Sets a list of comma-separated nicknames (without @ symbol in the beginning) for interacting with bot. Only these users can use the bot.
+115
View File
@@ -0,0 +1,115 @@
package telegram
import (
"errors"
"strings"
"sync"
"github.com/forestgiant/sliceutil"
"github.com/micro/go-micro/agent/input"
"github.com/micro/go-micro/util/log"
"gopkg.in/telegram-bot-api.v4"
)
type telegramConn struct {
input *telegramInput
recv <-chan tgbotapi.Update
exit chan bool
syncCond *sync.Cond
mutex sync.Mutex
}
func newConn(input *telegramInput) (*telegramConn, error) {
conn := &telegramConn{
input: input,
}
conn.syncCond = sync.NewCond(&conn.mutex)
go conn.run()
return conn, nil
}
func (tc *telegramConn) run() {
u := tgbotapi.NewUpdate(0)
u.Timeout = 60
updates, err := tc.input.api.GetUpdatesChan(u)
if err != nil {
return
}
tc.recv = updates
tc.syncCond.Signal()
for {
select {
case <-tc.exit:
return
}
}
}
func (tc *telegramConn) Close() error {
return nil
}
func (tc *telegramConn) Recv(event *input.Event) error {
if event == nil {
return errors.New("event cannot be nil")
}
for {
if tc.recv == nil {
tc.mutex.Lock()
tc.syncCond.Wait()
}
update := <-tc.recv
if update.Message == nil || (len(tc.input.whitelist) > 0 && !sliceutil.Contains(tc.input.whitelist, update.Message.From.UserName)) {
continue
}
if event.Meta == nil {
event.Meta = make(map[string]interface{})
}
event.Type = input.TextEvent
event.From = update.Message.From.UserName
event.To = tc.input.api.Self.UserName
event.Data = []byte(update.Message.Text)
event.Meta["chatId"] = update.Message.Chat.ID
event.Meta["chatType"] = update.Message.Chat.Type
event.Meta["messageId"] = update.Message.MessageID
return nil
}
}
func (tc *telegramConn) Send(event *input.Event) error {
messageText := strings.TrimSpace(string(event.Data))
chatId := event.Meta["chatId"].(int64)
chatType := ChatType(event.Meta["chatType"].(string))
msgConfig := tgbotapi.NewMessage(chatId, messageText)
msgConfig.ParseMode = tgbotapi.ModeHTML
if sliceutil.Contains([]ChatType{Group, Supergroup}, chatType) {
msgConfig.ReplyToMessageID = event.Meta["messageId"].(int)
}
_, err := tc.input.api.Send(msgConfig)
if err != nil {
// probably it could be because of nested HTML tags -- telegram doesn't allow nested tags
log.Log("[telegram][Send] error:", err)
msgConfig.Text = "This bot couldn't send the response (Internal error)"
tc.input.api.Send(msgConfig)
}
return nil
}
+101
View File
@@ -0,0 +1,101 @@
package telegram
import (
"errors"
"strings"
"sync"
"github.com/micro/cli"
"github.com/micro/go-micro/agent/input"
"gopkg.in/telegram-bot-api.v4"
)
type telegramInput struct {
sync.Mutex
debug bool
token string
whitelist []string
api *tgbotapi.BotAPI
}
type ChatType string
const (
Private ChatType = "private"
Group ChatType = "group"
Supergroup ChatType = "supergroup"
)
func init() {
input.Inputs["telegram"] = &telegramInput{}
}
func (ti *telegramInput) Flags() []cli.Flag {
return []cli.Flag{
cli.BoolFlag{
Name: "telegram_debug",
EnvVar: "MICRO_TELEGRAM_DEBUG",
Usage: "Telegram debug output",
},
cli.StringFlag{
Name: "telegram_token",
EnvVar: "MICRO_TELEGRAM_TOKEN",
Usage: "Telegram token",
},
cli.StringFlag{
Name: "telegram_whitelist",
EnvVar: "MICRO_TELEGRAM_WHITELIST",
Usage: "Telegram bot's users (comma-separated values)",
},
}
}
func (ti *telegramInput) Init(ctx *cli.Context) error {
ti.debug = ctx.Bool("telegram_debug")
ti.token = ctx.String("telegram_token")
whitelist := ctx.String("telegram_whitelist")
if whitelist != "" {
ti.whitelist = strings.Split(whitelist, ",")
}
if len(ti.token) == 0 {
return errors.New("missing telegram token")
}
return nil
}
func (ti *telegramInput) Stream() (input.Conn, error) {
ti.Lock()
defer ti.Unlock()
return newConn(ti)
}
func (ti *telegramInput) Start() error {
ti.Lock()
defer ti.Unlock()
api, err := tgbotapi.NewBotAPI(ti.token)
if err != nil {
return err
}
ti.api = api
api.Debug = ti.debug
return nil
}
func (ti *telegramInput) Stop() error {
return nil
}
func (p *telegramInput) String() string {
return "telegram"
}
-249
View File
@@ -1,249 +0,0 @@
package agent
import (
"context"
"io"
"strings"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/client"
codecBytes "go-micro.dev/v6/codec/bytes"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
// fakeGen drives the fake provider's Generate. Tests set it and reset
// it with a deferred cleanup. Tests in this package are not parallel,
// so a package-level hook is safe.
var fakeGen func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error)
var fakeStream func(ctx context.Context, opts ai.Options, req *ai.Request) (ai.Stream, error)
type fakeModel struct{ opts ai.Options }
func (m *fakeModel) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&m.opts)
}
return nil
}
func (m *fakeModel) Options() ai.Options { return m.opts }
func (m *fakeModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) {
if fakeGen != nil {
return fakeGen(ctx, m.opts, req)
}
return &ai.Response{Reply: "ok"}, nil
}
func (m *fakeModel) Stream(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (ai.Stream, error) {
if fakeStream != nil {
return fakeStream(ctx, m.opts, req)
}
return &sliceStream{chunks: []string{"ok"}}, nil
}
func (m *fakeModel) String() string { return "fake" }
type sliceStream struct {
chunks []string
idx int
closed bool
}
func (s *sliceStream) Recv() (*ai.Response, error) {
if s.idx >= len(s.chunks) {
return nil, io.EOF
}
chunk := s.chunks[s.idx]
s.idx++
return &ai.Response{Reply: chunk}, nil
}
func (s *sliceStream) Close() error {
s.closed = true
return nil
}
func init() {
ai.Register("fake", func(opts ...ai.Option) ai.Model {
m := &fakeModel{}
_ = m.Init(opts...)
return m
})
}
// fakeClient embeds the default client (so NewRequest works) and
// overrides Call with a test-supplied function.
type fakeClient struct {
client.Client
callFn func(ctx context.Context, req client.Request, rsp interface{}) error
}
func (c *fakeClient) Call(ctx context.Context, req client.Request, rsp interface{}, opts ...client.CallOption) error {
return c.callFn(ctx, req, rsp)
}
func newTestAgent(opts ...Option) *agentImpl {
base := []Option{
Provider("fake"),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
}
a := New(append(base, opts...)...).(*agentImpl)
a.setup()
return a
}
// The model is offered the plan and delegate tools, and calling the
// plan tool persists the plan to memory.
func TestAskExposesAndRunsPlan(t *testing.T) {
var sawPlan, sawDelegate bool
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
for _, tl := range req.Tools {
switch tl.Name {
case toolPlan:
sawPlan = true
case toolDelegate:
sawDelegate = true
}
}
// Simulate the model recording a plan.
if opts.ToolHandler != nil {
opts.ToolHandler(context.Background(), ai.ToolCall{
Name: toolPlan,
Input: map[string]any{
"steps": []any{map[string]any{"task": "step one", "status": "pending"}},
},
})
}
return &ai.Response{Answer: "done"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("worker"))
resp, err := a.Ask(context.Background(), "do some multi-step work")
if err != nil {
t.Fatalf("Ask: %v", err)
}
if !sawPlan || !sawDelegate {
t.Errorf("model should be offered plan and delegate tools: plan=%v delegate=%v", sawPlan, sawDelegate)
}
if resp.Reply == "" {
t.Error("Ask returned empty reply")
}
if plan := a.loadPlan(); !strings.Contains(plan, "step one") {
t.Errorf("plan tool result not persisted; loadPlan() = %q", plan)
}
}
// Delegating with no matching agent creates an ephemeral sub-agent with
// a fresh, isolated context (no builtin tools) and returns its reply.
func TestDelegateEphemeral(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if strings.Contains(req.SystemPrompt, "sub-agent") {
for _, tl := range req.Tools {
if tl.Name == toolPlan || tl.Name == toolDelegate {
t.Errorf("ephemeral sub-agent must not have builtin tool %q", tl.Name)
}
}
return &ai.Response{Reply: "subtask complete"}, nil
}
return &ai.Response{Reply: "parent"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("root"))
content := a.handleDelegate(context.Background(), ai.ToolCall{Name: "delegate", Input: map[string]any{"task": "summarize the report"}}).Content
if !strings.Contains(content, "subtask complete") {
t.Errorf("delegate should return the sub-agent's reply; got %q", content)
}
}
// Delegating to a name that resolves to a registered agent goes over
// RPC to that agent rather than spawning a sub-agent.
func TestDelegateToRegisteredAgent(t *testing.T) {
reg := registry.NewMemoryRegistry()
if err := reg.Register(&registry.Service{
Name: "comms",
Metadata: map[string]string{"type": "agent"},
Nodes: []*registry.Node{{Id: "comms-1", Address: "127.0.0.1:0"}},
}); err != nil {
t.Fatalf("register agent: %v", err)
}
var calledService, calledEndpoint string
fc := &fakeClient{Client: client.DefaultClient}
fc.callFn = func(ctx context.Context, req client.Request, rsp interface{}) error {
calledService, calledEndpoint = req.Service(), req.Endpoint()
frame := rsp.(*codecBytes.Frame)
frame.Data = []byte(`{"reply":"notified alice","agent":"comms"}`)
return nil
}
// fakeGen guards against the ephemeral path being taken by mistake.
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
t.Error("delegate to a registered agent must not spawn a sub-agent")
return &ai.Response{}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("root"), WithRegistry(reg), WithClient(fc))
content := a.handleDelegate(context.Background(), ai.ToolCall{Name: "delegate", Input: map[string]any{"task": "notify alice", "to": "comms"}}).Content
if calledService != "comms" || calledEndpoint != "Agent.Chat" {
t.Errorf("expected RPC to comms Agent.Chat, got %s %s", calledService, calledEndpoint)
}
if !strings.Contains(content, "notified alice") {
t.Errorf("delegate-first result missing agent reply; got %q", content)
}
}
// Delegate requires a task.
func TestDelegateRequiresTask(t *testing.T) {
a := newTestAgent(Name("root"))
content := a.handleDelegate(context.Background(), ai.ToolCall{Name: "delegate", Input: map[string]any{}}).Content
if !strings.Contains(content, "error") {
t.Errorf("delegate with no task should error; got %q", content)
}
}
func TestCompactingMemorySummarizesAndRecallsArchivedContext(t *testing.T) {
var sawSummary, sawRecall bool
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
for _, msg := range req.Messages {
text := msg.Content.(string)
if strings.Contains(text, "Conversation memory summary") && strings.Contains(text, "alpha project") {
sawSummary = true
}
if strings.Contains(text, "alpha project budget is 42") {
sawRecall = true
}
}
return &ai.Response{Reply: "ok"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("memory"), CompactMemory(4, 2), MemoryRecallLimit(3))
turns := []string{
"alpha project budget is 42",
"beta project owner is sam",
"gamma project deadline is monday",
"delta project status is green",
"epsilon project risk is low",
}
for _, turn := range turns {
if _, err := a.Ask(context.Background(), turn); err != nil {
t.Fatalf("Ask(%q): %v", turn, err)
}
}
if got := len(a.mem.Messages()); got > 4 {
t.Fatalf("compacted memory retained %d messages, want <= 4", got)
}
if _, err := a.Ask(context.Background(), "what was the alpha budget?"); err != nil {
t.Fatalf("Ask recall: %v", err)
}
if !sawSummary {
t.Error("model request did not include a deterministic compacted summary")
}
if !sawRecall {
t.Error("model request did not recall archived matching context")
}
}
-71
View File
@@ -1,71 +0,0 @@
package agent
import (
"strings"
"testing"
)
// Repeating the same tool call with the same arguments is refused once it
// exceeds LoopLimit, and the model is told to change approach.
func TestLoopDetectionStopsRepeats(t *testing.T) {
a := newTestAgent(Name("looper"), LoopLimit(3))
h := a.toolHandler()
// First 3 identical calls are allowed (they fall through to RPC,
// which fails harmlessly — we only care they weren't refused as loops).
for i := 1; i <= 3; i++ {
content := toolContent(h, "demo_Svc_Do", map[string]any{"q": "x"})
if strings.Contains(content, "loop detected") {
t.Fatalf("call %d wrongly flagged as a loop", i)
}
}
// The 4th identical call is refused as a loop.
content := toolContent(h, "demo_Svc_Do", map[string]any{"q": "x"})
if !strings.Contains(content, "loop detected") {
t.Errorf("4th identical call should be refused as a loop; got %q", content)
}
}
// Different arguments are not a loop, even past the limit.
func TestLoopDetectionAllowsDistinctCalls(t *testing.T) {
a := newTestAgent(Name("distinct"), LoopLimit(2))
h := a.toolHandler()
for i := 0; i < 5; i++ {
content := toolContent(h, "demo_Svc_Do", map[string]any{"q": i}) // distinct args each time
if strings.Contains(content, "loop detected") {
t.Fatalf("distinct call %d wrongly flagged as a loop", i)
}
}
}
// LoopLimit(0) disables detection.
func TestLoopDetectionDisabled(t *testing.T) {
a := newTestAgent(Name("noloop"), LoopLimit(0))
h := a.toolHandler()
for i := 0; i < 6; i++ {
content := toolContent(h, "demo_Svc_Do", map[string]any{"q": "same"})
if strings.Contains(content, "loop detected") {
t.Fatalf("loop detection should be disabled with LoopLimit(0)")
}
}
}
// It defaults on (lenient) so repeated identical calls are caught without
// any configuration.
func TestLoopDetectionDefaultOn(t *testing.T) {
a := New(Name("d"), Provider("fake")).(*agentImpl)
a.setup()
if a.opts.LoopLimit <= 0 {
t.Fatalf("LoopLimit should default on, got %d", a.opts.LoopLimit)
}
h := a.toolHandler()
var lastContent string
for i := 0; i < a.opts.LoopLimit+1; i++ {
lastContent = toolContent(h, "demo_Svc_Do", map[string]any{})
}
if !strings.Contains(lastContent, "loop detected") {
t.Errorf("default loop detection should catch repeated calls; got %q", lastContent)
}
}
-320
View File
@@ -1,320 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/store"
)
// Memory is an agent's conversation memory. Like the rest of the
// framework it is pluggable: the default is store-backed and durable
// across restarts, but any implementation can be supplied with
// WithMemory — in-process, a database, or a semantic/vector store.
type Memory interface {
// Add appends a message to the conversation.
Add(role, content string)
// Messages returns the retained conversation, oldest first.
Messages() []ai.Message
// Clear resets the conversation.
Clear()
}
// MemorySummaryFunc turns older conversation messages into a compact
// replacement message for active context. It is called while the default
// memory is locked, so implementations should be deterministic and avoid
// calling back into the same memory instance.
type MemorySummaryFunc func([]ai.Message) ai.Message
// MemoryCompaction configures deterministic, store-backed context compaction
// for the default memory implementation. When the retained conversation grows
// past MaxMessages, older turns are collapsed into a summary message while the
// newest KeepRecent turns stay verbatim for provider-neutral continuity.
type MemoryCompaction struct {
MaxMessages int
KeepRecent int
Summarize MemorySummaryFunc
}
// MemoryRecall is implemented by memory backends that can retrieve durable
// prior context relevant to a new turn without replaying every stored message.
type MemoryRecall interface {
Recall(query string, limit int) []ai.Message
}
// NewMemory returns the default store-backed memory: an in-process
// conversation buffer (truncated to limit) that persists to the store
// under key, so an agent picks up where it left off after a restart.
// A nil store or empty key yields non-persistent memory.
func NewMemory(s store.Store, key string, limit int) Memory {
m := &storeMemory{store: s, key: key, hist: ai.NewHistory(limit)}
m.load()
return m
}
// NewRetrievalMemory returns store-backed memory that keeps a bounded active
// conversation and archives every turn for retrieval. It is useful when callers
// want relevant durable recall without summary compaction in the active context.
// A nil store or empty key keeps only the active in-process buffer.
func NewRetrievalMemory(s store.Store, key string, activeLimit int) Memory {
m := &storeMemory{store: s, key: key, hist: ai.NewHistory(activeLimit), retrieveAll: true}
m.load()
return m
}
// NewCompactingMemory returns store-backed memory with explicit compaction and
// retrieval controls. It keeps all messages in the backing store, compacts older
// turns into a deterministic summary when the conversation exceeds maxMessages,
// and lets callers recall relevant prior turns with Recall.
func NewCompactingMemory(s store.Store, key string, maxMessages, keepRecent int) Memory {
return NewCompactingMemoryWithOptions(s, key, MemoryCompaction{MaxMessages: maxMessages, KeepRecent: keepRecent})
}
// NewCompactingMemoryWithOptions returns store-backed memory configured with
// explicit compaction options, including an optional summarization hook.
func NewCompactingMemoryWithOptions(s store.Store, key string, compaction MemoryCompaction) Memory {
maxMessages := compaction.MaxMessages
keepRecent := compaction.KeepRecent
if keepRecent <= 0 {
keepRecent = maxMessages / 2
}
if keepRecent < 1 {
keepRecent = 1
}
m := &storeMemory{
store: s,
key: key,
// Use an unlimited buffer here; compaction, not truncation, decides
// what remains in active context so a summary can preserve older turns.
hist: ai.NewHistory(0),
compaction: MemoryCompaction{
MaxMessages: maxMessages,
KeepRecent: keepRecent,
Summarize: compaction.Summarize,
},
}
m.load()
m.compact()
return m
}
// NewInMemory returns conversation memory that is not persisted.
func NewInMemory(limit int) Memory {
return &storeMemory{hist: ai.NewHistory(limit)}
}
// storeMemory is the default Memory: an ai.History buffer optionally
// persisted to a store.
type storeMemory struct {
mu sync.Mutex
store store.Store
key string
hist *ai.History
compaction MemoryCompaction
archive []ai.Message
retrieveAll bool
}
func (m *storeMemory) Add(role, content string) {
m.mu.Lock()
if m.retrieveAll {
m.archive = append(m.archive, ai.Message{Role: role, Content: content})
}
m.hist.Add(role, content)
m.mu.Unlock()
m.compact()
m.save()
}
func (m *storeMemory) Messages() []ai.Message {
m.mu.Lock()
defer m.mu.Unlock()
return m.hist.Messages()
}
func (m *storeMemory) Clear() {
m.mu.Lock()
m.hist.Reset()
m.archive = nil
m.mu.Unlock()
m.save()
}
// Recall returns archived messages whose content contains words from query.
// It is deterministic and provider-neutral: no embeddings or model calls are
// required, but semantic/vector stores can replace Memory for richer retrieval.
// When created with NewRetrievalMemory the archive contains every persisted
// turn; when created with NewCompactingMemory it contains compacted older turns.
func (m *storeMemory) Recall(query string, limit int) []ai.Message {
m.mu.Lock()
defer m.mu.Unlock()
if limit <= 0 {
limit = 5
}
terms := recallTerms(query)
type match struct {
msg ai.Message
score int
index int
}
matches := make([]match, 0, len(m.archive))
for i := len(m.archive) - 1; i >= 0; i-- {
msg := m.archive[i]
if score := recallScore(msg, terms); score > 0 {
matches = append(matches, match{msg: msg, score: score, index: i})
}
}
sort.SliceStable(matches, func(i, j int) bool {
if matches[i].score != matches[j].score {
return matches[i].score > matches[j].score
}
return matches[i].index > matches[j].index
})
if len(matches) > limit {
matches = matches[:limit]
}
out := make([]ai.Message, 0, len(matches))
for _, match := range matches {
out = append(out, match.msg)
}
return out
}
func (m *storeMemory) load() {
if m.store == nil || m.key == "" {
return
}
recs, err := m.store.Read(m.key)
if err != nil || len(recs) == 0 {
return
}
var state memoryState
if err := json.Unmarshal(recs[0].Value, &state); err != nil {
var msgs []ai.Message
if err := json.Unmarshal(recs[0].Value, &msgs); err != nil {
return
}
state.Messages = msgs
}
m.mu.Lock()
m.archive = state.Archive
if m.retrieveAll && len(m.archive) == 0 {
m.archive = append(m.archive, state.Messages...)
}
for _, msg := range state.Messages {
m.hist.Add(msg.Role, msg.Content)
}
m.mu.Unlock()
}
func (m *storeMemory) save() {
if m.store == nil || m.key == "" {
return
}
m.mu.Lock()
data, err := json.Marshal(memoryState{
Messages: m.hist.Messages(),
Archive: m.archive,
})
m.mu.Unlock()
if err != nil {
return
}
_ = m.store.Write(&store.Record{Key: m.key, Value: data})
}
func (m *storeMemory) compact() {
if m.compaction.MaxMessages <= 0 {
return
}
m.mu.Lock()
defer m.mu.Unlock()
msgs := m.hist.Messages()
if len(msgs) <= m.compaction.MaxMessages {
return
}
keep := m.compaction.KeepRecent
if keep <= 0 || keep >= m.compaction.MaxMessages {
keep = m.compaction.MaxMessages - 1
}
if keep < 1 {
keep = 1
}
cut := len(msgs) - keep
older := msgs[:cut]
recent := msgs[cut:]
m.archive = append(m.archive, older...)
summarize := m.compaction.Summarize
if summarize == nil {
summarize = defaultMemorySummary
}
summary := summarize(older)
if summary.Role == "" {
summary.Role = "system"
}
m.hist.Reset()
m.hist.Add(summary.Role, summary.Content)
for _, msg := range recent {
m.hist.Add(msg.Role, msg.Content)
}
}
func defaultMemorySummary(msgs []ai.Message) ai.Message {
return ai.Message{
Role: "system",
Content: fmt.Sprintf("Conversation memory summary: %s", summarizeMessages(msgs)),
}
}
func summarizeMessages(msgs []ai.Message) string {
var b strings.Builder
for i, msg := range msgs {
if i > 0 {
b.WriteString(" | ")
}
fmt.Fprintf(&b, "%s: %s", msg.Role, compactText(fmt.Sprint(msg.Content), 120))
}
return b.String()
}
func compactText(s string, max int) string {
s = strings.Join(strings.Fields(s), " ")
if max > 0 && len(s) > max {
return s[:max] + "…"
}
return s
}
func recallScore(msg ai.Message, terms []string) int {
text := strings.ToLower(fmt.Sprint(msg.Content))
score := 0
for _, term := range terms {
if strings.Contains(text, term) {
score++
}
}
return score
}
func recallTerms(query string) []string {
seen := map[string]bool{}
var terms []string
for _, term := range strings.Fields(strings.ToLower(query)) {
term = strings.Trim(term, ".,!?;:\"'()[]{}")
if len(term) < 3 || seen[term] {
continue
}
seen[term] = true
terms = append(terms, term)
}
return terms
}
type memoryState struct {
Messages []ai.Message `json:"messages"`
Archive []ai.Message `json:"archive,omitempty"`
}
-229
View File
@@ -1,229 +0,0 @@
package agent
import (
"context"
"errors"
"strconv"
"strings"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
func TestStoreMemoryPersists(t *testing.T) {
st := store.NewMemoryStore()
m := NewMemory(st, "agent/x/history", 10)
m.Add("user", "hello")
m.Add("assistant", "hi there")
// A fresh memory over the same store/key restores the conversation.
reloaded := NewMemory(st, "agent/x/history", 10)
if got := len(reloaded.Messages()); got != 2 {
t.Fatalf("restored %d messages, want 2", got)
}
}
func TestInMemoryNotPersisted(t *testing.T) {
m := NewInMemory(10)
m.Add("user", "x")
if got := len(m.Messages()); got != 1 {
t.Fatalf("got %d messages, want 1", got)
}
if got := len(NewInMemory(10).Messages()); got != 0 {
t.Errorf("a separate in-memory should be empty, got %d", got)
}
}
func TestMemoryClearPersists(t *testing.T) {
st := store.NewMemoryStore()
m := NewMemory(st, "agent/y/history", 10)
m.Add("user", "x")
m.Clear()
if got := len(m.Messages()); got != 0 {
t.Errorf("after Clear got %d messages, want 0", got)
}
if got := len(NewMemory(st, "agent/y/history", 10).Messages()); got != 0 {
t.Errorf("cleared state should persist, reload got %d", got)
}
}
func TestWithMemoryUsed(t *testing.T) {
custom := NewInMemory(5)
a := New(
Name("z"),
Provider("fake"),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WithMemory(custom),
).(*agentImpl)
a.setup()
if a.mem != custom {
t.Error("WithMemory should make the agent use the supplied memory")
}
}
func TestRetrievalMemoryArchivesAllTurnsAndRanksRelevant(t *testing.T) {
st := store.NewMemoryStore()
m := NewRetrievalMemory(st, "agent/retrieval/history", 2)
m.Add("user", "alpha budget is 42")
m.Add("assistant", "noted")
m.Add("user", "beta owner is lee")
m.Add("assistant", "tracked")
m.Add("user", "alpha owner is sam")
if got := len(m.Messages()); got != 2 {
t.Fatalf("active messages = %d, want bounded history of 2", got)
}
recall, ok := m.(MemoryRecall)
if !ok {
t.Fatal("retrieval memory should support recall")
}
recalled := recall.Recall("alpha budget", 2)
if len(recalled) == 0 {
t.Fatal("expected relevant recalled turns")
}
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
t.Fatalf("top recall = %q, want archived alpha budget turn", got)
}
}
func TestRetrievalMemoryPersistsArchiveAcrossReload(t *testing.T) {
st := store.NewMemoryStore()
m := NewRetrievalMemory(st, "agent/retrieval/reload", 1)
m.Add("user", "alpha budget is 42")
m.Add("assistant", "noted")
m.Add("user", "beta budget is 7")
reloaded := NewRetrievalMemory(st, "agent/retrieval/reload", 1)
recalled := reloaded.(MemoryRecall).Recall("alpha budget", 1)
if len(recalled) != 1 {
t.Fatalf("recalled %d messages, want 1", len(recalled))
}
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
t.Fatalf("reloaded recall = %q, want alpha budget", got)
}
}
func TestCompactingMemoryRecallRanksSpecificMatches(t *testing.T) {
m := NewCompactingMemory(store.NewMemoryStore(), "agent/rank/history", 3, 1).(MemoryRecall)
writer := m.(Memory)
writer.Add("user", "alpha budget is 42")
writer.Add("assistant", "noted")
writer.Add("user", "beta budget is 7")
writer.Add("assistant", "noted")
writer.Add("user", "alpha owner is sam")
recalled := m.Recall("alpha budget", 2)
if len(recalled) == 0 {
t.Fatal("expected recalled messages")
}
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
t.Fatalf("top recall = %q, want alpha budget match", got)
}
}
func TestCompactingMemoryArchivePersistsAndReloads(t *testing.T) {
st := store.NewMemoryStore()
m := NewCompactingMemory(st, "agent/reload/history", 3, 1)
m.Add("user", "alpha budget is 42")
m.Add("assistant", "noted")
m.Add("user", "beta budget is 7")
m.Add("assistant", "noted")
reloaded := NewCompactingMemory(st, "agent/reload/history", 3, 1)
recall, ok := reloaded.(MemoryRecall)
if !ok {
t.Fatal("compacting memory should support recall")
}
recalled := recall.Recall("alpha budget", 1)
if len(recalled) != 1 {
t.Fatalf("recalled %d messages, want 1", len(recalled))
}
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
t.Fatalf("reloaded recall = %q, want alpha budget", got)
}
}
func TestCompactingMemoryUsesCustomSummarizerAndReloadsRecall(t *testing.T) {
st := store.NewMemoryStore()
m := NewCompactingMemoryWithOptions(st, "agent/custom/history", MemoryCompaction{
MaxMessages: 3,
KeepRecent: 1,
Summarize: func(msgs []ai.Message) ai.Message {
return ai.Message{Role: "system", Content: "custom summary count=" + strconv.Itoa(len(msgs))}
},
})
m.Add("user", "alpha budget is 42")
m.Add("assistant", "noted")
m.Add("user", "beta budget is 7")
m.Add("assistant", "noted")
msgs := m.Messages()
if len(msgs) == 0 || msgs[0].Content != "custom summary count=3" {
t.Fatalf("summary = %#v, want custom summarizer output", msgs)
}
reloaded := NewCompactingMemoryWithOptions(st, "agent/custom/history", MemoryCompaction{MaxMessages: 3, KeepRecent: 1})
recall := reloaded.(MemoryRecall)
recalled := recall.Recall("alpha budget", 1)
if len(recalled) != 1 {
t.Fatalf("recalled %d messages, want 1", len(recalled))
}
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
t.Fatalf("reloaded recall = %q, want alpha budget", got)
}
}
// A custom tool is offered to the model and dispatched to its handler.
func TestWithToolExposedAndDispatched(t *testing.T) {
var got map[string]any
a := newTestAgent(Name("calc-agent"),
WithTool("calc", "adds two numbers",
map[string]any{
"a": map[string]any{"type": "number"},
"b": map[string]any{"type": "number"},
},
func(ctx context.Context, input map[string]any) (string, error) {
got = input
return `{"sum":3}`, nil
}))
tools, err := a.discoverTools()
if err != nil {
t.Fatalf("discoverTools: %v", err)
}
found := false
for _, tl := range tools {
if tl.Name == "calc" {
found = true
}
}
if !found {
t.Fatal("custom tool 'calc' was not offered to the model")
}
content := toolContent(a.toolHandler(), "calc", map[string]any{"a": 1.0, "b": 2.0})
if got == nil {
t.Fatal("custom tool handler was not called")
}
if !strings.Contains(content, "sum") {
t.Errorf("custom tool result not returned: %q", content)
}
}
// A custom tool returning an error surfaces it to the model.
func TestWithToolError(t *testing.T) {
a := newTestAgent(Name("err-agent"),
WithTool("boom", "always fails", nil,
func(ctx context.Context, input map[string]any) (string, error) {
return "", errors.New("kaboom")
}))
content := toolContent(a.toolHandler(), "boom", nil)
if !strings.Contains(content, "kaboom") {
t.Errorf("tool error not surfaced: %q", content)
}
}
-372
View File
@@ -1,372 +0,0 @@
package agent
import (
"context"
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/client"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
"go.opentelemetry.io/otel/trace"
)
// Option configures an Agent.
type Option func(*Options)
// ApproveFunc decides whether an agent may execute a tool call before it
// runs. Returning false blocks the call; the reason is shown to the
// model so it can adapt. Use it for human-in-the-loop approval or policy
// checks. It is called for actions (service tools and delegate), not for
// the internal plan tool.
type ApproveFunc func(tool string, input map[string]any) (approved bool, reason string)
// ToolFunc handles a custom tool call. Return the result as a string
// (often JSON); return an error to report failure back to the model.
type ToolFunc func(ctx context.Context, input map[string]any) (string, error)
// customTool is a developer-registered tool beyond the agent's services.
type customTool struct {
def ai.Tool
handler ToolFunc
}
// Options holds agent configuration.
type Options struct {
Name string
Services []string
Prompt string
Provider string
Model string
APIKey string
BaseURL string
Address string
Registry registry.Registry
Client client.Client
Store store.Store
HistoryLimit int
// ModelTimeout bounds each provider Generate call (0 disables).
ModelTimeout time.Duration
// ModelMaxAttempts bounds provider Generate attempts including the first
// call. Default 1 — retries are opt-in (enable with ModelRetry). A Generate
// runs the whole tool-execution turn, so auto-retrying it would re-run
// already-executed, possibly side-effecting tool calls; keep it explicit.
ModelMaxAttempts int
// ModelRetryBackoff is the base delay between transient provider failures
// (grows exponentially per attempt when retries are enabled).
ModelRetryBackoff time.Duration
// ToolTimeout bounds each tool execution (0 disables). The timeout is
// applied before custom tools, delegate, and service RPC calls so context
// deadlines propagate consistently through the agent loop.
ToolTimeout time.Duration
// ToolMaxAttempts bounds tool execution attempts including the first call.
// Default 1; retries are opt-in because tools can have side effects.
ToolMaxAttempts int
// ToolRetryBackoff is the base delay between transient tool failures.
ToolRetryBackoff time.Duration
// Memory is the agent's conversation memory. Nil = the default
// store-backed memory (durable across restarts).
Memory Memory
// MemoryRetrievalLimit enables retrieval-backed default memory without
// compaction. The active conversation stays bounded to this many messages
// while every turn is archived for deterministic recall.
MemoryRetrievalLimit int
// MemoryCompaction enables deterministic compaction/retrieval on the
// default store-backed memory. Custom Memory implementations can expose
// retrieval by implementing MemoryRecall.
MemoryCompaction MemoryCompaction
// MemoryRecallLimit bounds recalled archived turns injected into a model
// request (0 disables recall injection).
MemoryRecallLimit int
// Checkpoint persists agent Ask runs so callers can resume by run id
// after a restart without replaying a run that already completed.
Checkpoint flow.Checkpoint
// MaxSteps bounds the number of tool executions per Ask (0 =
// unbounded). Once exceeded, further tool calls are refused and the
// model is told to stop and summarize. A stopping condition.
MaxSteps int
// LoopLimit bounds how many times the agent may call the same tool
// with the same arguments in one Ask before the call is refused as a
// no-progress loop (0 = disabled). Catches the agent repeating an
// identical action — which MaxSteps only bounds by total count.
LoopLimit int
// Approve gates each action before it runs. Nil = allow all.
Approve ApproveFunc
// A2AAddress, if set, makes Run serve this agent over the A2A protocol
// on that address directly (no separate gateway), e.g. ":4000".
A2AAddress string
// TraceProvider enables OpenTelemetry spans for agent runs, model calls,
// and tool calls. Nil disables instrumentation.
TraceProvider trace.TracerProvider
// TraceInputs controls whether agent observability records include raw
// user messages. It is false by default so spans and persisted run
// timelines carry correlation and shape without leaking prompts.
TraceInputs bool
// tools are developer-registered custom tools (see WithTool).
tools []customTool
// wrappers are developer-registered tool-execution wrappers
// (see WrapTool), applied outside the built-in guardrails.
wrappers []ai.ToolWrapper
}
func newOptions(opts ...Option) Options {
o := Options{
Registry: registry.DefaultRegistry,
Client: client.DefaultClient,
Store: store.DefaultStore,
HistoryLimit: 50,
ModelTimeout: 30 * time.Second,
ModelMaxAttempts: 1, // retries opt-in via ModelRetry (see field doc)
ModelRetryBackoff: 100 * time.Millisecond,
ToolTimeout: 30 * time.Second,
ToolMaxAttempts: 1,
ToolRetryBackoff: 100 * time.Millisecond,
// On by default and lenient: identical repeated calls are a
// no-progress loop, never useful. Set LoopLimit(0) to disable.
LoopLimit: 3,
}
for _, opt := range opts {
opt(&o)
}
return o
}
// Name sets the agent name.
func Name(n string) Option {
return func(o *Options) { o.Name = n }
}
// Services sets which services this agent manages.
func Services(names ...string) Option {
return func(o *Options) { o.Services = names }
}
// Prompt sets the system prompt.
func Prompt(p string) Option {
return func(o *Options) { o.Prompt = p }
}
// Provider sets the LLM provider.
func Provider(p string) Option {
return func(o *Options) { o.Provider = p }
}
// Model sets the LLM model name.
func Model(m string) Option {
return func(o *Options) { o.Model = m }
}
// APIKey sets the API key for the LLM provider.
func APIKey(k string) Option {
return func(o *Options) { o.APIKey = k }
}
// BaseURL sets the base URL for the LLM provider. Use this to point
// the provider at a non-default endpoint (e.g., local Ollama, a proxy).
func BaseURL(url string) Option {
return func(o *Options) { o.BaseURL = url }
}
// Address sets the network address for the agent's service endpoint.
// Use "127.0.0.1:0" in local harnesses/tests to bind an ephemeral loopback
// port and avoid advertising the default service address.
func Address(addr string) Option {
return func(o *Options) { o.Address = addr }
}
// WithRegistry sets the service registry.
func WithRegistry(r registry.Registry) Option {
return func(o *Options) { o.Registry = r }
}
// WithClient sets the RPC client.
func WithClient(c client.Client) Option {
return func(o *Options) { o.Client = c }
}
// WithStore sets the store for agent memory.
func WithStore(s store.Store) Option {
return func(o *Options) { o.Store = s }
}
// HistoryLimit sets the max conversation messages to retain.
func HistoryLimit(n int) Option {
return func(o *Options) { o.HistoryLimit = n }
}
// MaxSteps bounds tool executions per Ask (0 = unbounded). A stopping
// condition: beyond the limit, tool calls are refused and the model is
// told to stop and summarize.
func MaxSteps(n int) Option {
return func(o *Options) { o.MaxSteps = n }
}
// ApproveTool sets a human-in-the-loop / policy hook called before each
// action (service tools and delegate). Returning false blocks the call.
func ApproveTool(fn ApproveFunc) Option {
return func(o *Options) { o.Approve = fn }
}
// LoopLimit sets how many times the agent may repeat the same tool call
// (same name and arguments) in one Ask before it is refused as a
// no-progress loop. 0 disables loop detection.
func LoopLimit(n int) Option {
return func(o *Options) { o.LoopLimit = n }
}
// ModelCallTimeout sets the timeout for each provider Generate call.
func ModelCallTimeout(d time.Duration) Option {
return func(o *Options) { o.ModelTimeout = d }
}
// ToolCallTimeout sets the timeout for each tool execution. It bounds custom
// tools, built-in delegate calls, and service RPC tools with the same context
// deadline so mid-run cancellation and slow tools produce safe error results
// instead of unbounded agent runs. Set 0 to disable.
func ToolCallTimeout(d time.Duration) Option {
return func(o *Options) { o.ToolTimeout = d }
}
// ModelRetry sets the provider retry budget and backoff for transient failures.
func ModelRetry(maxAttempts int, backoff time.Duration) Option {
return func(o *Options) {
o.ModelMaxAttempts = maxAttempts
o.ModelRetryBackoff = backoff
}
}
// ToolRetry sets the tool retry budget and backoff for transient failures.
// Attempts include the first call. Retries are opt-in because tools may have
// side effects; keep handlers idempotent before enabling this.
func ToolRetry(maxAttempts int, backoff time.Duration) Option {
return func(o *Options) {
o.ToolMaxAttempts = maxAttempts
o.ToolRetryBackoff = backoff
}
}
// WithA2A makes Run serve the agent over the A2A protocol on addr (e.g.
// ":4000"), so other agents can reach it directly by URL without a
// separate gateway. The agent stays a normal go-micro service as well;
// this adds a second, A2A-native HTTP endpoint that calls it in-process.
func WithA2A(addr string) Option {
return func(o *Options) { o.A2AAddress = addr }
}
// WithMemory sets the agent's conversation memory. The default is
// store-backed memory keyed by agent name; supply your own to use an
// in-process, database, or semantic store.
func WithMemory(m Memory) Option {
return func(o *Options) { o.Memory = m }
}
// RetrievalMemory enables deterministic, store-backed retrieval memory for
// the default agent memory without compaction. Active context is capped at
// activeLimit messages while every turn is archived in the store for Recall.
func RetrievalMemory(activeLimit int) Option {
return func(o *Options) {
o.MemoryRetrievalLimit = activeLimit
if o.MemoryRecallLimit == 0 {
o.MemoryRecallLimit = 5
}
}
}
// CompactMemory enables deterministic, store-backed memory compaction for the
// default agent memory. Older turns are summarized once active context exceeds
// maxMessages, keepRecent newest turns remain verbatim, and recalled archived
// turns are injected into matching future asks.
func CompactMemory(maxMessages, keepRecent int) Option {
return func(o *Options) {
o.MemoryCompaction.MaxMessages = maxMessages
o.MemoryCompaction.KeepRecent = keepRecent
if o.MemoryRecallLimit == 0 {
o.MemoryRecallLimit = 5
}
}
}
// MemorySummarizer sets the deterministic summarization hook used by the
// default compacting memory. It is optional; without it, compacted memory uses
// a provider-neutral text summary. The hook receives the older messages being
// removed from active context and returns the replacement summary message.
func MemorySummarizer(fn MemorySummaryFunc) Option {
return func(o *Options) { o.MemoryCompaction.Summarize = fn }
}
// MemoryRecallLimit sets how many archived turns a memory backend may inject
// into a model request for the current Ask. Use 0 to disable retrieval.
func MemoryRecallLimit(n int) Option {
return func(o *Options) { o.MemoryRecallLimit = n }
}
// WithCheckpoint sets the durability backend for agent Ask runs. The
// Checkpoint interface is shared with flow so services, agents, and workflows
// can use one execution history backend. When set, each Ask is saved as a
// single-step run keyed by run id; Resume returns a completed run's persisted
// response instead of calling the model again.
func WithCheckpoint(c flow.Checkpoint) Option {
return func(o *Options) { o.Checkpoint = c }
}
// WrapTool registers a tool-execution wrapper, the tool-side analog of
// a client/server middleware wrapper. Each wrapper takes the next handler
// and returns a new one; code before the next(...) call runs before the
// tool executes, code after runs after. Use it for logging, metrics,
// retries, or custom policy. Wrappers run outside the built-in guardrails
// (MaxSteps, LoopLimit, ApproveTool), so they observe every call and its
// result, including refusals. Multiple wrappers compose outermost-first.
//
// micro.NewAgent("worker", micro.AgentWrapTool(
// func(next ai.ToolHandler) ai.ToolHandler {
// return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
// res := next(ctx, call)
// log.Printf("id=%s tool=%s", call.ID, call.Name)
// return res
// }
// }))
func WrapTool(w ...ai.ToolWrapper) Option {
return func(o *Options) {
o.wrappers = append(o.wrappers, w...)
}
}
// WithTool registers a custom tool the agent can call, beyond the
// services it discovers — a local function, an external API, anything.
// properties is the JSON-schema map for the tool's parameters.
func WithTool(name, description string, properties map[string]any, handler ToolFunc) Option {
return func(o *Options) {
o.tools = append(o.tools, customTool{
def: ai.Tool{
Name: name,
OriginalName: name,
Description: description,
Properties: properties,
},
handler: handler,
})
}
}
// TraceProvider enables OpenTelemetry tracing for agent runs. The persisted
// run timeline is recorded even when TraceProvider is nil; trace/span IDs are
// added only when a provider is configured.
func TraceProvider(tp trace.TracerProvider) Option {
return func(o *Options) { o.TraceProvider = tp }
}
// TraceInputs opts in to recording raw user messages on agent run events.
// By default inputs are redacted from OpenTelemetry spans and persisted run
// timelines; use this only when the observability backend is approved to store
// prompt content.
func TraceInputs(enabled bool) Option {
return func(o *Options) { o.TraceInputs = enabled }
}
-666
View File
@@ -1,666 +0,0 @@
package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"sort"
"strings"
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/store"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
const agentInstrumentationName = "go-micro.dev/v6/agent"
const (
spanNameRun = "agent.run"
spanNameModelCall = "agent.model.call"
spanNameModelStream = "agent.model.stream"
spanNameToolCall = "agent.tool.call"
AttrRunID = "agent.run.id"
AttrParentRunID = "agent.run.parent_id"
AttrAgentName = "agent.name"
AttrProvider = "agent.model.provider"
AttrModel = "agent.model.name"
AttrLatencyMS = "agent.latency_ms"
AttrInputTokens = "agent.tokens.input"
AttrOutputTokens = "agent.tokens.output"
AttrTotalTokens = "agent.tokens.total"
AttrAttempt = "agent.model.attempt"
AttrMaxAttempts = "agent.model.max_attempts"
AttrToolName = "agent.tool.name"
AttrDelegate = "agent.delegate"
AttrGuardrailBlock = "agent.guardrail.block"
AttrRefusal = "agent.refusal"
AttrInputChars = "agent.input.chars"
AttrErrorKind = "agent.error.kind"
AttrCheckpointStatus = "agent.checkpoint.status"
AttrCheckpointStage = "agent.checkpoint.stage"
AttrFlowName = "agent.flow.name"
AttrFlowStep = "agent.flow.step"
AttrDispatch = "agent.dispatch"
AttrTrigger = "agent.trigger"
AttrRunEventKind = "agent.event.kind"
)
type RunEvent struct {
Time time.Time `json:"time"`
RunID string `json:"run_id"`
ParentID string `json:"parent_id,omitempty"`
TraceID string `json:"trace_id,omitempty"`
SpanID string `json:"span_id,omitempty"`
Agent string `json:"agent"`
Kind string `json:"kind"`
Name string `json:"name,omitempty"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
Attempt int `json:"attempt,omitempty"`
MaxAttempts int `json:"max_attempts,omitempty"`
LatencyMS int64 `json:"latency_ms,omitempty"`
Tokens Usage `json:"tokens,omitempty"`
Refused string `json:"refused,omitempty"`
Status string `json:"status,omitempty"`
Error string `json:"error,omitempty"`
ErrorKind string `json:"error_kind,omitempty"`
InputChars int `json:"input_chars,omitempty"`
}
type Usage = ai.Usage
// RunListOptions controls how recorded agent run summaries are returned.
// Zero values preserve the full deterministic run list.
type RunListOptions struct {
// Status, when set, keeps only runs with the matching status
// (for example "running", "done", "canceled", "timeout",
// "rate_limited", "error", or "refused").
Status string
// TraceID, when set, keeps only runs correlated with this trace id.
// A prefix is accepted so operators can paste the shortened trace id
// printed by `micro runs`.
TraceID string
// Limit, when positive, returns the most recently updated runs up to
// the limit. Limited results are ordered newest first.
Limit int
}
// RunSummary is a compact index entry for a recorded agent run.
type RunSummary struct {
RunID string `json:"run_id"`
Agent string `json:"agent"`
ParentID string `json:"parent_id,omitempty"`
TraceID string `json:"trace_id,omitempty"`
SpanID string `json:"span_id,omitempty"`
StartedAt time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
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"`
}
func (a *agentImpl) tracer() trace.Tracer {
return a.opts.TraceProvider.Tracer(agentInstrumentationName)
}
func (a *agentImpl) startRun(ctx context.Context, message string) (context.Context, func(error)) {
info, _ := ai.RunInfoFrom(ctx)
start := time.Now()
runEvent := RunEvent{Time: start, RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "run", InputChars: len(message)}
if a.opts.TraceInputs {
runEvent.Name = message
}
if a.opts.TraceProvider == nil {
a.recordRunEvent(runEvent)
return ctx, func(err error) {
latency := time.Since(start).Milliseconds()
if err != nil {
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "error", LatencyMS: latency, Error: err.Error(), ErrorKind: string(ai.ClassifyError(err))})
return
}
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "done", LatencyMS: latency})
}
}
attrs := appendRunInfoAttributes([]attribute.KeyValue{
attribute.String(AttrRunID, info.RunID),
attribute.String(AttrParentRunID, info.ParentID),
attribute.String(AttrAgentName, info.Agent),
}, info)
ctx, span := a.tracer().Start(ctx, spanNameRun, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(attrs...))
a.recordSpanEvent(span, runEvent)
return ctx, func(err error) {
latency := time.Since(start).Milliseconds()
span.SetAttributes(attribute.Int64(AttrLatencyMS, latency))
if err != nil {
span.SetAttributes(attribute.String(AttrErrorKind, string(ai.ClassifyError(err))))
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "error", LatencyMS: latency, Error: err.Error(), ErrorKind: string(ai.ClassifyError(err))})
} else {
span.SetStatus(codes.Ok, "")
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "done", LatencyMS: latency})
}
span.End()
}
}
type tracedModel struct {
ai.Model
a *agentImpl
}
func (a *agentImpl) tracedModel(m ai.Model) ai.Model { return &tracedModel{Model: m, a: a} }
func (m *tracedModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
info, _ := ai.RunInfoFrom(ctx)
provider := m.String()
model := m.Options().Model
start := time.Now()
if m.a.opts.TraceProvider == nil {
resp, err := m.Model.Generate(ctx, req, opts...)
dur := time.Since(start).Milliseconds()
usage := ai.Usage{}
if resp != nil {
usage = resp.Usage
}
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.recordRunEvent(e)
return resp, err
}
attrs := appendRunInfoAttributes([]attribute.KeyValue{
attribute.String(AttrRunID, info.RunID),
attribute.String(AttrParentRunID, info.ParentID),
attribute.String(AttrAgentName, info.Agent),
attribute.String(AttrProvider, provider),
attribute.String(AttrModel, model),
}, info)
ctx, span := m.a.tracer().Start(ctx, spanNameModelCall, trace.WithAttributes(attrs...))
resp, err := m.Model.Generate(ctx, req, opts...)
dur := time.Since(start).Milliseconds()
attrs = []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
if info.Attempt > 0 {
attrs = append(attrs, attribute.Int(AttrAttempt, info.Attempt))
}
if info.MaxAttempts > 0 {
attrs = append(attrs, attribute.Int(AttrMaxAttempts, info.MaxAttempts))
}
usage := ai.Usage{}
if resp != nil {
usage = resp.Usage
attrs = appendUsage(attrs, usage)
}
span.SetAttributes(attrs...)
if err != nil {
span.SetAttributes(attribute.String(AttrErrorKind, string(ai.ClassifyError(err))))
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
} 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)
return resp, err
}
func (m *tracedModel) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
info, _ := ai.RunInfoFrom(ctx)
provider := m.String()
model := m.Options().Model
start := time.Now()
if m.a.opts.TraceProvider == nil {
stream, err := m.Model.Stream(ctx, req, opts...)
if err != nil {
m.a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "stream", Provider: provider, Model: model, Attempt: info.Attempt, MaxAttempts: info.MaxAttempts, LatencyMS: time.Since(start).Milliseconds(), Error: err.Error(), ErrorKind: string(ai.ClassifyError(err))})
return nil, err
}
return &tracedStream{Stream: stream, a: m.a, info: info, provider: provider, model: model, start: start}, nil
}
attrs := appendRunInfoAttributes([]attribute.KeyValue{
attribute.String(AttrRunID, info.RunID),
attribute.String(AttrParentRunID, info.ParentID),
attribute.String(AttrAgentName, info.Agent),
attribute.String(AttrProvider, provider),
attribute.String(AttrModel, model),
}, info)
ctx, span := m.a.tracer().Start(ctx, spanNameModelStream, trace.WithAttributes(attrs...))
stream, err := m.Model.Stream(ctx, req, opts...)
if err != nil {
dur := time.Since(start).Milliseconds()
span.SetAttributes(attribute.Int64(AttrLatencyMS, dur), attribute.String(AttrErrorKind, string(ai.ClassifyError(err))))
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
e := RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "stream", Provider: provider, Model: model, Attempt: info.Attempt, MaxAttempts: info.MaxAttempts, LatencyMS: dur, Error: err.Error(), ErrorKind: string(ai.ClassifyError(err))}
m.a.recordSpanEvent(span, e)
span.End()
return nil, err
}
return &tracedStream{Stream: stream, a: m.a, info: info, provider: provider, model: model, start: start, span: span}, nil
}
type tracedStream struct {
ai.Stream
a *agentImpl
info ai.RunInfo
provider string
model string
start time.Time
span trace.Span
usage ai.Usage
closed bool
}
func (s *tracedStream) Recv() (*ai.Response, error) {
resp, err := s.Stream.Recv()
if resp != nil {
s.usage = mergeUsage(s.usage, resp.Usage)
}
if err != nil {
if errors.Is(err, io.EOF) {
s.finish(nil)
} else {
s.finish(err)
}
}
return resp, err
}
func (s *tracedStream) Close() error {
err := s.Stream.Close()
s.finish(err)
return err
}
func (s *tracedStream) finish(err error) {
if s.closed {
return
}
s.closed = true
dur := time.Since(s.start).Milliseconds()
e := RunEvent{Time: time.Now(), RunID: s.info.RunID, ParentID: s.info.ParentID, Agent: s.info.Agent, Kind: "stream", Provider: s.provider, Model: s.model, Attempt: s.info.Attempt, MaxAttempts: s.info.MaxAttempts, LatencyMS: dur, Tokens: s.usage}
if err != nil {
e.Error = err.Error()
e.ErrorKind = string(ai.ClassifyError(err))
}
if s.span == nil {
s.a.recordRunEvent(e)
return
}
attrs := appendUsage([]attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}, s.usage)
if s.info.Attempt > 0 {
attrs = append(attrs, attribute.Int(AttrAttempt, s.info.Attempt))
}
if s.info.MaxAttempts > 0 {
attrs = append(attrs, attribute.Int(AttrMaxAttempts, s.info.MaxAttempts))
}
if err != nil {
attrs = append(attrs, attribute.String(AttrErrorKind, e.ErrorKind))
s.span.RecordError(err)
s.span.SetStatus(codes.Error, err.Error())
} else {
s.span.SetStatus(codes.Ok, "")
}
s.span.SetAttributes(attrs...)
s.a.recordSpanEvent(s.span, e)
s.span.End()
}
func mergeUsage(current, next ai.Usage) ai.Usage {
if next.InputTokens > current.InputTokens {
current.InputTokens = next.InputTokens
}
if next.OutputTokens > current.OutputTokens {
current.OutputTokens = next.OutputTokens
}
if next.TotalTokens > current.TotalTokens {
current.TotalTokens = next.TotalTokens
}
return current
}
func appendUsage(attrs []attribute.KeyValue, u ai.Usage) []attribute.KeyValue {
if u.InputTokens > 0 {
attrs = append(attrs, attribute.Int(AttrInputTokens, u.InputTokens))
}
if u.OutputTokens > 0 {
attrs = append(attrs, attribute.Int(AttrOutputTokens, u.OutputTokens))
}
if u.TotalTokens > 0 {
attrs = append(attrs, attribute.Int(AttrTotalTokens, u.TotalTokens))
}
return attrs
}
func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
info, _ := ai.RunInfoFrom(ctx)
start := time.Now()
if a.opts.TraceProvider == nil {
res := next(ctx, call)
dur := time.Since(start).Milliseconds()
resErr := resultError(res)
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
}
ctx, span := a.tracer().Start(ctx, spanNameToolCall, trace.WithAttributes(
attribute.String(AttrRunID, info.RunID),
attribute.String(AttrParentRunID, info.ParentID),
attribute.String(AttrAgentName, info.Agent),
attribute.String(AttrToolName, call.Name),
attribute.Bool(AttrDelegate, call.Name == toolDelegate),
))
res := next(ctx, call)
dur := time.Since(start).Milliseconds()
attrs := []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
if res.Refused != "" {
attrs = append(attrs, attribute.Bool(AttrGuardrailBlock, true), attribute.String(AttrRefusal, res.Refused))
}
resErr := resultError(res)
if kind := classifyToolError(resErr); kind != "" {
attrs = append(attrs, attribute.String(AttrErrorKind, kind))
}
span.SetAttributes(attrs...)
if res.Refused != "" {
span.SetStatus(codes.Error, res.Refused)
} else if resErr != "" {
span.SetStatus(codes.Error, resErr)
} else {
span.SetStatus(codes.Ok, "")
}
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
}
}
func resultError(res ai.ToolResult) string {
if m, ok := res.Value.(map[string]string); ok {
return m["error"]
}
if m, ok := res.Value.(map[string]any); ok {
if err, _ := m["error"].(string); err != "" {
return err
}
}
return ""
}
func classifyToolError(err string) string {
switch {
case err == "":
return ""
case strings.Contains(strings.ToLower(err), "context canceled"):
return string(ai.ErrorKindCanceled)
case strings.Contains(strings.ToLower(err), "deadline exceeded"):
return string(ai.ErrorKindTimeout)
default:
return string(ai.ErrorKindProvider)
}
}
func (a *agentImpl) recordTimelineEvent(ctx context.Context, e RunEvent) {
span := trace.SpanFromContext(ctx)
if span.SpanContext().IsValid() {
a.recordSpanEvent(span, e)
return
}
a.recordRunEvent(e)
}
func (a *agentImpl) recordSpanEvent(span trace.Span, e RunEvent) {
if sc := span.SpanContext(); sc.IsValid() {
e.TraceID = sc.TraceID().String()
e.SpanID = sc.SpanID().String()
}
span.AddEvent("agent."+e.Kind, trace.WithTimestamp(e.Time), trace.WithAttributes(runEventAttributes(e)...))
a.recordRunEvent(e)
}
func runEventAttributes(e RunEvent) []attribute.KeyValue {
attrs := []attribute.KeyValue{
attribute.String(AttrRunID, e.RunID),
attribute.String(AttrAgentName, e.Agent),
attribute.String(AttrRunEventKind, e.Kind),
}
if e.ParentID != "" {
attrs = append(attrs, attribute.String(AttrParentRunID, e.ParentID))
}
if e.Name != "" {
attrs = append(attrs, attribute.String("agent.event.name", e.Name))
}
if e.Provider != "" {
attrs = append(attrs, attribute.String(AttrProvider, e.Provider))
}
if e.Model != "" {
attrs = append(attrs, attribute.String(AttrModel, e.Model))
}
if e.Attempt > 0 {
attrs = append(attrs, attribute.Int(AttrAttempt, e.Attempt))
}
if e.MaxAttempts > 0 {
attrs = append(attrs, attribute.Int(AttrMaxAttempts, e.MaxAttempts))
}
if e.LatencyMS > 0 {
attrs = append(attrs, attribute.Int64(AttrLatencyMS, e.LatencyMS))
}
if e.InputChars > 0 {
attrs = append(attrs, attribute.Int(AttrInputChars, e.InputChars))
}
attrs = appendUsage(attrs, e.Tokens)
if e.Refused != "" {
attrs = append(attrs, attribute.Bool(AttrGuardrailBlock, true), attribute.String(AttrRefusal, e.Refused))
}
if e.Error != "" {
attrs = append(attrs, attribute.String("agent.error", e.Error))
}
if e.ErrorKind != "" {
attrs = append(attrs, attribute.String(AttrErrorKind, e.ErrorKind))
}
if e.Kind == "checkpoint" {
if e.Status != "" {
attrs = append(attrs, attribute.String(AttrCheckpointStatus, e.Status))
}
if e.Name != "" {
attrs = append(attrs, attribute.String(AttrCheckpointStage, e.Name))
}
}
return attrs
}
func appendRunInfoAttributes(attrs []attribute.KeyValue, info ai.RunInfo) []attribute.KeyValue {
if info.Flow != "" {
attrs = append(attrs, attribute.String(AttrFlowName, info.Flow))
}
if info.Step != "" {
attrs = append(attrs, attribute.String(AttrFlowStep, info.Step))
}
if info.Dispatch != "" {
attrs = append(attrs, attribute.String(AttrDispatch, info.Dispatch))
}
if info.Trigger != "" {
attrs = append(attrs, attribute.String(AttrTrigger, info.Trigger))
}
return attrs
}
func (a *agentImpl) recordRunEvent(e RunEvent) {
if e.RunID == "" {
return
}
b, _ := json.Marshal(e)
key := fmt.Sprintf("runs/%s/%020d-%s", e.RunID, e.Time.UnixNano(), e.Kind)
_ = a.stateStore().Write(&store.Record{Key: key, Value: b})
}
// ListRunSummaries returns a deterministic summary of recorded runs for agentName.
func ListRunSummaries(s store.Store, agentName string) ([]RunSummary, error) {
return ListRunSummariesWithOptions(s, agentName, RunListOptions{})
}
// ListRunSummariesWithOptions returns summaries of recorded runs for agentName,
// optionally filtered by status and limited to the most recently updated runs.
func ListRunSummariesWithOptions(s store.Store, agentName string, opts RunListOptions) ([]RunSummary, error) {
st := store.Scope(s, "agent", agentName)
keys, err := st.List(store.ListPrefix("runs/"))
if err != nil {
return nil, err
}
runs := map[string]bool{}
for _, k := range keys {
parts := strings.Split(k, "/")
if len(parts) >= 2 && parts[1] != "" {
runs[parts[1]] = true
}
}
ids := make([]string, 0, len(runs))
for id := range runs {
ids = append(ids, id)
}
sort.Strings(ids)
summaries := make([]RunSummary, 0, len(ids))
for _, id := range ids {
events, err := LoadRunEvents(s, agentName, id)
if err != nil {
return nil, err
}
if len(events) == 0 {
continue
}
first := events[0]
last := events[len(events)-1]
summary := RunSummary{
RunID: id,
Agent: first.Agent,
ParentID: first.ParentID,
TraceID: first.TraceID,
SpanID: first.SpanID,
StartedAt: first.Time,
UpdatedAt: last.Time,
DurationMS: last.Time.Sub(first.Time).Milliseconds(),
Events: len(events),
Status: runStatus(events),
LastKind: last.Kind,
LastError: last.Error,
}
for _, e := range events {
if e.Agent != "" {
summary.Agent = e.Agent
}
if e.ParentID != "" {
summary.ParentID = e.ParentID
}
if e.TraceID != "" {
summary.TraceID = e.TraceID
}
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
}
if e.ErrorKind != "" {
summary.LastErrorKind = e.ErrorKind
}
}
if opts.Status != "" && summary.Status != opts.Status {
continue
}
if opts.TraceID != "" && !strings.HasPrefix(summary.TraceID, opts.TraceID) {
continue
}
summaries = append(summaries, summary)
}
if opts.Limit > 0 {
sort.SliceStable(summaries, func(i, j int) bool {
return summaries[i].UpdatedAt.After(summaries[j].UpdatedAt)
})
if len(summaries) > opts.Limit {
summaries = summaries[:opts.Limit]
}
}
return summaries, nil
}
func runStatus(events []RunEvent) string {
if len(events) == 0 {
return ""
}
status := "running"
for _, e := range events {
if e.Refused != "" && status == "running" {
status = "refused"
}
if e.Error != "" || e.Kind == "error" {
status = runErrorStatus(e.ErrorKind)
}
if e.Kind == "done" && status == "running" {
status = "done"
}
}
return status
}
func runErrorStatus(kind string) string {
switch ai.ErrorKind(kind) {
case ai.ErrorKindCanceled:
return "canceled"
case ai.ErrorKindTimeout:
return "timeout"
case ai.ErrorKindRateLimited:
return "rate_limited"
default:
return "error"
}
}
func LoadRunEvents(s store.Store, agentName, runID string) ([]RunEvent, error) {
st := store.Scope(s, "agent", agentName)
keys, err := st.List(store.ListPrefix("runs/" + runID + "/"))
if err != nil {
return nil, err
}
sort.Strings(keys)
events := make([]RunEvent, 0, len(keys))
for _, k := range keys {
recs, err := st.Read(k)
if err != nil || len(recs) == 0 {
continue
}
var e RunEvent
if json.Unmarshal(recs[0].Value, &e) == nil {
events = append(events, e)
}
}
return events, nil
}
-671
View File
@@ -1,671 +0,0 @@
package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"testing"
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/store"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
)
const codesError = codes.Error
type otelTestModel struct{ opts ai.Options }
func (m *otelTestModel) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&m.opts)
}
return nil
}
func (m *otelTestModel) Options() ai.Options { return m.opts }
func (m *otelTestModel) String() string { return "oteltest" }
func (m *otelTestModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
return nil, nil
}
func (m *otelTestModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
if m.opts.ToolHandler != nil {
if strings.Contains(req.Prompt, "delegate") {
_ = m.opts.ToolHandler(ctx, ai.ToolCall{ID: "call-delegate", Name: toolDelegate, Input: map[string]any{"task": "subtask"}})
} else if !strings.Contains(req.Prompt, "subtask") {
_ = m.opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "probe", Input: map[string]any{"ok": true}})
}
}
return &ai.Response{Reply: "done", Usage: ai.Usage{InputTokens: 2, OutputTokens: 3, TotalTokens: 5}}, nil
}
func init() {
ai.Register("oteltest", func(opts ...ai.Option) ai.Model { return &otelTestModel{opts: ai.NewOptions(opts...)} })
}
func TestAgentOpenTelemetrySpans(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
st := store.NewMemoryStore()
a := New(Name("runner"), Provider("oteltest"), Model("unit-model"), WithStore(st), TraceProvider(tp), WithTool("probe", "probe", nil, func(context.Context, map[string]any) (string, error) { return "ok", nil }))
if _, err := a.Ask(context.Background(), "hello"); err != nil {
t.Fatal(err)
}
spans := exp.GetSpans().Snapshots()
want := map[string]bool{spanNameRun: false, spanNameModelCall: false, spanNameToolCall: false}
var runID string
for _, s := range spans {
if _, ok := want[s.Name()]; ok {
want[s.Name()] = true
}
attrs := spanAttributes(s.Attributes())
if s.Name() == spanNameRun {
runID = attrs[AttrRunID]
}
}
for name, seen := range want {
if !seen {
t.Fatalf("span %s not emitted; got %d spans", name, len(spans))
}
}
if runID == "" {
t.Fatal("run span missing run id attribute")
}
var runEvents []trace.Event
for _, s := range spans {
if s.Name() == spanNameRun {
runEvents = s.Events()
break
}
}
if !spanEventHasRunInfo(runEvents, "agent.run", runID, "runner") || !spanEventHasRunInfo(runEvents, "agent.done", runID, "runner") {
t.Fatalf("run span missing run-info events: %#v", runEvents)
}
for _, s := range spans {
if s.Name() != spanNameModelCall && s.Name() != spanNameToolCall {
continue
}
attrs := spanAttributes(s.Attributes())
if attrs[AttrRunID] != runID || attrs[AttrAgentName] != "runner" {
t.Fatalf("%s missing run correlation attributes: %#v", s.Name(), attrs)
}
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/"))
if err != nil {
t.Fatal(err)
}
if len(keys) == 0 {
t.Fatal("expected run events to be recorded")
}
summaries, err := ListRunSummaries(st, "runner")
if err != nil {
t.Fatal(err)
}
if len(summaries) != 1 {
t.Fatalf("got %d summaries, want 1", len(summaries))
}
if summaries[0].LastKind != "done" {
t.Fatalf("LastKind = %q, want done", summaries[0].LastKind)
}
if summaries[0].Status != "done" {
t.Fatalf("Status = %q, want done", summaries[0].Status)
}
if summaries[0].DurationMS < 0 {
t.Fatalf("DurationMS = %d, want non-negative", summaries[0].DurationMS)
}
if summaries[0].TraceID == "" || summaries[0].SpanID == "" {
t.Fatalf("summary missing trace correlation: %#v", summaries[0])
}
events, err := LoadRunEvents(st, "runner", summaries[0].RunID)
if err != nil {
t.Fatal(err)
}
if len(events) == 0 || events[0].TraceID == "" || events[0].SpanID == "" {
t.Fatalf("events missing trace correlation: %#v", events)
}
}
func TestAgentRunObservabilityRedactsInputByDefault(t *testing.T) {
secret := "deploy production with token sk-secret"
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
st := store.NewMemoryStore()
a := New(Name("redactor"), Provider("oteltest"), WithStore(st), TraceProvider(tp))
if _, err := a.Ask(context.Background(), secret); err != nil {
t.Fatal(err)
}
spans := exp.GetSpans().Snapshots()
var sawInputChars bool
for _, s := range spans {
for _, event := range s.Events() {
attrs := spanAttributes(event.Attributes)
if attrs["agent.event.name"] == secret {
t.Fatalf("span event leaked raw input: %#v", event)
}
if attrs[AttrInputChars] == fmt.Sprint(len(secret)) {
sawInputChars = true
}
}
}
if !sawInputChars {
t.Fatal("run event missing redacted input length attribute")
}
summaries, err := ListRunSummaries(st, "redactor")
if err != nil {
t.Fatal(err)
}
events, err := LoadRunEvents(st, "redactor", summaries[0].RunID)
if err != nil {
t.Fatal(err)
}
for _, event := range events {
if event.Name == secret {
t.Fatalf("persisted run event leaked raw input: %#v", event)
}
if event.Kind == "run" && event.InputChars != len(secret) {
t.Fatalf("run event InputChars = %d, want %d", event.InputChars, len(secret))
}
}
}
func TestAgentTraceInputsOptInRecordsInput(t *testing.T) {
message := "operator-approved diagnostic prompt"
st := store.NewMemoryStore()
a := New(Name("input-opt-in"), Provider("oteltest"), WithStore(st), TraceInputs(true))
if _, err := a.Ask(context.Background(), message); err != nil {
t.Fatal(err)
}
summaries, err := ListRunSummaries(st, "input-opt-in")
if err != nil {
t.Fatal(err)
}
events, err := LoadRunEvents(st, "input-opt-in", summaries[0].RunID)
if err != nil {
t.Fatal(err)
}
for _, event := range events {
if event.Kind == "run" && event.Name == message {
return
}
}
t.Fatalf("opt-in run event did not record message: %#v", events)
}
type failingOtelModel struct{ opts ai.Options }
func (m *failingOtelModel) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&m.opts)
}
return nil
}
func (m *failingOtelModel) Options() ai.Options { return m.opts }
func (m *failingOtelModel) String() string { return "otelfail" }
func (m *failingOtelModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
return nil, nil
}
func (m *failingOtelModel) Generate(context.Context, *ai.Request, ...ai.GenerateOption) (*ai.Response, error) {
return nil, errors.New("provider exploded")
}
func init() {
ai.Register("otelfail", func(opts ...ai.Option) ai.Model { return &failingOtelModel{opts: ai.NewOptions(opts...)} })
}
func TestAgentOpenTelemetrySpansModelFailure(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
st := store.NewMemoryStore()
a := New(Name("failing-runner"), Provider("otelfail"), WithStore(st), TraceProvider(tp))
if _, err := a.Ask(context.Background(), "hello"); err == nil {
t.Fatal("Ask succeeded, want provider error")
}
spans := exp.GetSpans().Snapshots()
var sawRunError, sawModelError bool
for _, s := range spans {
attrs := spanAttributes(s.Attributes())
switch s.Name() {
case spanNameRun:
if attrs[AttrAgentName] == "failing-runner" && s.Status().Code == codesError {
sawRunError = true
}
case spanNameModelCall:
if attrs[AttrAgentName] == "failing-runner" && attrs[AttrAttempt] == "1" && attrs[AttrErrorKind] == string(ai.ErrorKindUnknown) && s.Status().Code == codesError {
sawModelError = true
}
}
}
if !sawRunError || !sawModelError {
t.Fatalf("missing error spans: run=%v model=%v spans=%d", sawRunError, sawModelError, len(spans))
}
summaries, err := ListRunSummaries(st, "failing-runner")
if err != nil {
t.Fatal(err)
}
if len(summaries) != 1 || summaries[0].Status != "error" || summaries[0].LastError == "" {
t.Fatalf("unexpected failure summary: %#v", summaries)
}
events, err := LoadRunEvents(st, "failing-runner", summaries[0].RunID)
if err != nil {
t.Fatal(err)
}
var sawModelEvent bool
for _, event := range events {
if event.Kind == "model" && event.Attempt == 1 && event.MaxAttempts == 1 && event.Error != "" && event.ErrorKind == string(ai.ErrorKindUnknown) {
sawModelEvent = true
}
}
if !sawModelEvent {
t.Fatalf("missing failed model event with attempt metadata: %#v", events)
}
}
func spanEventHasRunInfo(events []trace.Event, name, runID, agentName string) bool {
for _, event := range events {
if event.Name != name {
continue
}
attrs := spanAttributes(event.Attributes)
wantKind := strings.TrimPrefix(name, "agent.")
if attrs[AttrRunID] == runID && attrs[AttrAgentName] == agentName && attrs[AttrRunEventKind] == wantKind {
return true
}
}
return false
}
func spanAttributes(attrs []attribute.KeyValue) map[string]string {
out := make(map[string]string, len(attrs))
for _, attr := range attrs {
out[string(attr.Key)] = fmt.Sprint(attr.Value.AsInterface())
}
return out
}
func TestAgentOpenTelemetrySpansDelegateLineage(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
st := store.NewMemoryStore()
a := New(Name("conductor"), Provider("oteltest"), WithStore(st), TraceProvider(tp))
if _, err := a.Ask(context.Background(), "delegate please"); err != nil {
t.Fatal(err)
}
spans := exp.GetSpans().Snapshots()
var parentRunID string
var delegateSpanID string
var subRunSeen bool
for _, s := range spans {
attrs := spanAttributes(s.Attributes())
if s.Name() == spanNameRun && attrs[AttrAgentName] == "conductor" {
parentRunID = attrs[AttrRunID]
}
}
if parentRunID == "" {
t.Fatal("parent run span missing run id")
}
for _, s := range spans {
attrs := spanAttributes(s.Attributes())
if s.Name() == spanNameToolCall && attrs[AttrToolName] == toolDelegate {
if attrs[AttrDelegate] != "true" || attrs[AttrRunID] != parentRunID {
t.Fatalf("delegate span missing correlation attributes: %#v", attrs)
}
delegateSpanID = s.SpanContext().SpanID().String()
}
}
if delegateSpanID == "" {
t.Fatal("delegate tool span not emitted")
}
for _, s := range spans {
attrs := spanAttributes(s.Attributes())
if s.Name() == spanNameRun && attrs[AttrAgentName] == "conductor.sub" {
if attrs[AttrParentRunID] != parentRunID {
t.Fatalf("sub-agent run parent attr = %q, want %q", attrs[AttrParentRunID], parentRunID)
}
if s.Parent().SpanID().String() != delegateSpanID {
t.Fatalf("sub-agent run parent span = %s, want delegate span %s", s.Parent().SpanID(), delegateSpanID)
}
subRunSeen = true
}
}
if !subRunSeen {
t.Fatalf("sub-agent run span not emitted; got %d spans", len(spans))
}
}
func TestAgentRunTimelineRecordsModelAndToolWithoutTraceProvider(t *testing.T) {
st := store.NewMemoryStore()
a := New(Name("runner-noop"), Provider("oteltest"), WithStore(st), WithTool("probe", "probe", nil, func(context.Context, map[string]any) (string, error) { return "ok", nil }))
if _, err := a.Ask(context.Background(), "hello"); err != nil {
t.Fatal(err)
}
keys, err := store.Scope(st, "agent", "runner-noop").List(store.ListPrefix("runs/"))
if err != nil {
t.Fatal(err)
}
if len(keys) == 0 {
t.Fatal("expected run timeline without TraceProvider")
}
summaries, err := ListRunSummaries(st, "runner-noop")
if err != nil {
t.Fatal(err)
}
if len(summaries) != 1 {
t.Fatalf("got %d summaries, want 1", len(summaries))
}
if summaries[0].Status != "done" || summaries[0].LastKind != "done" {
t.Fatalf("unexpected summary without TraceProvider: %#v", summaries[0])
}
if summaries[0].TraceID != "" || summaries[0].SpanID != "" {
t.Fatalf("unexpected trace correlation without TraceProvider: %#v", summaries[0])
}
events, err := LoadRunEvents(st, "runner-noop", summaries[0].RunID)
if err != nil {
t.Fatal(err)
}
seen := map[string]bool{"run": false, "model": false, "tool": false, "done": false}
for _, e := range events {
seen[e.Kind] = true
if e.TraceID != "" || e.SpanID != "" {
t.Fatalf("event has trace correlation without TraceProvider: %#v", e)
}
}
for kind, ok := range seen {
if !ok {
t.Fatalf("missing %s event in timeline: %#v", kind, events)
}
}
}
func TestAgentCheckpointAndResumeTimelineEvents(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
st := store.NewMemoryStore()
cp := flow.StoreCheckpoint(st, "resume-otel-agent")
first := true
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if first {
first = false
return nil, errors.New("temporary provider failure")
}
return &ai.Response{Reply: "resumed"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("resume-otel-agent"), WithStore(st), WithCheckpoint(cp), TraceProvider(tp))
_, err := a.Ask(context.Background(), "resume me")
if err == nil {
t.Fatal("Ask succeeded, want simulated failure")
}
runs, err := cp.List(context.Background())
if err != nil {
t.Fatal(err)
}
if len(runs) != 1 {
t.Fatalf("checkpointed runs = %d, want 1", len(runs))
}
resp, err := Resume(context.Background(), a, runs[0].ID)
if err != nil {
t.Fatalf("Resume: %v", err)
}
if resp.Reply != "resumed" {
t.Fatalf("reply = %q, want resumed", resp.Reply)
}
events, err := LoadRunEvents(st, "resume-otel-agent", runs[0].ID)
if err != nil {
t.Fatal(err)
}
seen := map[string]bool{"checkpoint": false, "resume": false}
for _, e := range events {
if _, ok := seen[e.Kind]; ok {
seen[e.Kind] = true
}
}
for kind, ok := range seen {
if !ok {
t.Fatalf("missing %s event in timeline: %#v", kind, events)
}
}
var resumeSpanEvent bool
for _, s := range exp.GetSpans().Snapshots() {
if s.Name() != spanNameRun {
continue
}
for _, e := range s.Events() {
if e.Name == "agent.resume" {
resumeSpanEvent = true
}
}
}
if !resumeSpanEvent {
t.Fatal("run span missing agent.resume event")
}
}
func TestLoadRunEventsSortsTimelineKeys(t *testing.T) {
st := store.NewMemoryStore()
scoped := store.Scope(st, "agent", "runner")
runID := "run-1"
events := []RunEvent{
{Time: time.Unix(0, 3), RunID: runID, Agent: "runner", Kind: "tool", Name: "third"},
{Time: time.Unix(0, 1), RunID: runID, Agent: "runner", Kind: "run", Name: "first"},
{Time: time.Unix(0, 2), RunID: runID, Agent: "runner", Kind: "model", Name: "second"},
}
for _, e := range events {
b, err := json.Marshal(e)
if err != nil {
t.Fatal(err)
}
key := "runs/" + runID + "/" + e.Time.Format("20060102150405.000000000") + "-" + e.Kind
if err := scoped.Write(&store.Record{Key: key, Value: b}); err != nil {
t.Fatal(err)
}
}
got, err := LoadRunEvents(st, "runner", runID)
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Fatalf("got %d events, want 3", len(got))
}
for i, want := range []string{"first", "second", "third"} {
if got[i].Name != want {
t.Fatalf("event %d = %q, want %q (timeline: %#v)", i, got[i].Name, want, got)
}
}
}
func TestListRunSummaries(t *testing.T) {
st := store.NewMemoryStore()
scoped := store.Scope(st, "agent", "runner")
events := []RunEvent{
{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)},
}
for _, e := range events {
b, err := json.Marshal(e)
if err != nil {
t.Fatal(err)
}
key := "runs/" + e.RunID + "/" + e.Time.Format("20060102150405.000000000") + "-" + e.Kind
if err := scoped.Write(&store.Record{Key: key, Value: b}); err != nil {
t.Fatal(err)
}
}
got, err := ListRunSummaries(st, "runner")
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("got %d summaries, want 2: %#v", len(got), got)
}
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) {
t.Fatalf("unexpected run-b summary: %#v", got[1])
}
}
func TestRunStatusClassifiesOperationalErrorKinds(t *testing.T) {
tests := []struct {
name string
kind ai.ErrorKind
want string
}{
{name: "canceled", kind: ai.ErrorKindCanceled, want: "canceled"},
{name: "timeout", kind: ai.ErrorKindTimeout, want: "timeout"},
{name: "rate limited", kind: ai.ErrorKindRateLimited, want: "rate_limited"},
{name: "provider", kind: ai.ErrorKindProvider, want: "error"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := runStatus([]RunEvent{
{Kind: "run"},
{Kind: "error", Error: "failed", ErrorKind: string(tt.kind)},
})
if got != tt.want {
t.Fatalf("runStatus() = %q, want %q", got, tt.want)
}
})
}
}
func TestListRunSummariesWithOptionsFiltersAndLimits(t *testing.T) {
st := store.NewMemoryStore()
scoped := store.Scope(st, "agent", "runner")
events := []RunEvent{
{Time: time.Unix(0, 1), RunID: "run-old", Agent: "runner", Kind: "run"},
{Time: time.Unix(0, 2), RunID: "run-old", Agent: "runner", Kind: "done"},
{Time: time.Unix(0, 3), RunID: "run-new", Agent: "runner", TraceID: "abcdef1234567890", Kind: "run"},
{Time: time.Unix(0, 4), RunID: "run-new", Agent: "runner", Kind: "error", Error: "rate limit exceeded", ErrorKind: string(ai.ErrorKindRateLimited)},
}
for _, e := range events {
b, err := json.Marshal(e)
if err != nil {
t.Fatal(err)
}
if err := scoped.Write(&store.Record{Key: "runs/" + e.RunID + "/" + e.Time.Format("20060102150405.000000000") + "-" + e.Kind, Value: b}); err != nil {
t.Fatal(err)
}
}
got, err := ListRunSummariesWithOptions(st, "runner", RunListOptions{Status: "rate_limited", TraceID: "abcdef", Limit: 1})
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].RunID != "run-new" || got[0].Status != "rate_limited" {
t.Fatalf("filtered summaries = %#v", got)
}
}
type otelStreamModel struct{ opts ai.Options }
func (m *otelStreamModel) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&m.opts)
}
return nil
}
func (m *otelStreamModel) Options() ai.Options { return m.opts }
func (m *otelStreamModel) String() string { return "otelstream" }
func (m *otelStreamModel) Generate(context.Context, *ai.Request, ...ai.GenerateOption) (*ai.Response, error) {
return &ai.Response{Reply: "unused"}, nil
}
func (m *otelStreamModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
return &otelTestStream{chunks: []*ai.Response{{Reply: "one", Usage: ai.Usage{InputTokens: 1, OutputTokens: 2, TotalTokens: 3}}, {Reply: "two", Usage: ai.Usage{InputTokens: 1, OutputTokens: 4, TotalTokens: 5}}}}, nil
}
type otelTestStream struct {
chunks []*ai.Response
idx int
}
func (s *otelTestStream) Recv() (*ai.Response, error) {
if s.idx >= len(s.chunks) {
return nil, io.EOF
}
resp := s.chunks[s.idx]
s.idx++
return resp, nil
}
func (s *otelTestStream) Close() error { return nil }
func TestAgentOpenTelemetrySpansModelStream(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
st := store.NewMemoryStore()
a := New(Name("stream-runner"), Provider("oteltest"), Model("stream-model"), WithStore(st), TraceProvider(tp))
m := a.(*agentImpl).tracedModel(&otelStreamModel{opts: ai.Options{Model: "stream-model"}})
ctx := ai.WithRunInfo(context.Background(), ai.RunInfo{RunID: "stream-run-1", ParentID: "parent-run", Agent: "stream-runner", Attempt: 2, MaxAttempts: 3, Flow: "deploy", Step: "plan"})
stream, err := m.Stream(ctx, &ai.Request{Prompt: "stream"})
if err != nil {
t.Fatal(err)
}
for {
_, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatal(err)
}
}
if err := stream.Close(); err != nil {
t.Fatal(err)
}
spans := exp.GetSpans().Snapshots()
var sawStream bool
for _, s := range spans {
if s.Name() != spanNameModelStream {
continue
}
attrs := spanAttributes(s.Attributes())
if attrs[AttrRunID] != "stream-run-1" || attrs[AttrParentRunID] != "parent-run" || attrs[AttrAgentName] != "stream-runner" {
t.Fatalf("stream span missing run lineage: %#v", attrs)
}
if attrs[AttrFlowName] != "deploy" || attrs[AttrFlowStep] != "plan" {
t.Fatalf("stream span missing workflow attributes: %#v", attrs)
}
if attrs[AttrAttempt] != "2" || attrs[AttrMaxAttempts] != "3" || attrs[AttrTotalTokens] != "5" {
t.Fatalf("stream span missing attempt/usage attributes: %#v", attrs)
}
sawStream = true
}
if !sawStream {
t.Fatalf("stream span not emitted; got %d spans", len(spans))
}
events, err := LoadRunEvents(st, "stream-runner", "stream-run-1")
if err != nil {
t.Fatal(err)
}
if len(events) != 1 || events[0].Kind != "stream" || events[0].TraceID == "" || events[0].SpanID == "" || events[0].Tokens.TotalTokens != 5 {
t.Fatalf("unexpected stream run event: %#v", events)
}
}
-297
View File
@@ -1,297 +0,0 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v3.21.12
// source: agent/proto/agent.proto
package agent
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type ChatRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
// parent_id correlates this chat with the workflow or agent run that dispatched it.
ParentId string `protobuf:"bytes,2,opt,name=parent_id,json=parentId,proto3" json:"parent_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ChatRequest) Reset() {
*x = ChatRequest{}
mi := &file_agent_proto_agent_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ChatRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ChatRequest) ProtoMessage() {}
func (x *ChatRequest) ProtoReflect() protoreflect.Message {
mi := &file_agent_proto_agent_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ChatRequest.ProtoReflect.Descriptor instead.
func (*ChatRequest) Descriptor() ([]byte, []int) {
return file_agent_proto_agent_proto_rawDescGZIP(), []int{0}
}
func (x *ChatRequest) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
func (x *ChatRequest) GetParentId() string {
if x != nil {
return x.ParentId
}
return ""
}
type ChatResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Reply string `protobuf:"bytes,1,opt,name=reply,proto3" json:"reply,omitempty"`
Agent string `protobuf:"bytes,2,opt,name=agent,proto3" json:"agent,omitempty"`
ToolCalls []*ToolCall `protobuf:"bytes,3,rep,name=tool_calls,json=toolCalls,proto3" json:"tool_calls,omitempty"`
// run_id correlates this chat response with tool calls, traces, and run history.
RunId string `protobuf:"bytes,4,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"`
// parent_id is set when this response belongs to a delegated sub-agent run.
ParentId string `protobuf:"bytes,5,opt,name=parent_id,json=parentId,proto3" json:"parent_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ChatResponse) Reset() {
*x = ChatResponse{}
mi := &file_agent_proto_agent_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ChatResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ChatResponse) ProtoMessage() {}
func (x *ChatResponse) ProtoReflect() protoreflect.Message {
mi := &file_agent_proto_agent_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ChatResponse.ProtoReflect.Descriptor instead.
func (*ChatResponse) Descriptor() ([]byte, []int) {
return file_agent_proto_agent_proto_rawDescGZIP(), []int{1}
}
func (x *ChatResponse) GetReply() string {
if x != nil {
return x.Reply
}
return ""
}
func (x *ChatResponse) GetAgent() string {
if x != nil {
return x.Agent
}
return ""
}
func (x *ChatResponse) GetToolCalls() []*ToolCall {
if x != nil {
return x.ToolCalls
}
return nil
}
func (x *ChatResponse) GetRunId() string {
if x != nil {
return x.RunId
}
return ""
}
func (x *ChatResponse) GetParentId() string {
if x != nil {
return x.ParentId
}
return ""
}
type ToolCall struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
Input string `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"`
Result string `protobuf:"bytes,4,opt,name=result,proto3" json:"result,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ToolCall) Reset() {
*x = ToolCall{}
mi := &file_agent_proto_agent_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ToolCall) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ToolCall) ProtoMessage() {}
func (x *ToolCall) ProtoReflect() protoreflect.Message {
mi := &file_agent_proto_agent_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ToolCall.ProtoReflect.Descriptor instead.
func (*ToolCall) Descriptor() ([]byte, []int) {
return file_agent_proto_agent_proto_rawDescGZIP(), []int{2}
}
func (x *ToolCall) GetId() string {
if x != nil {
return x.Id
}
return ""
}
func (x *ToolCall) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *ToolCall) GetInput() string {
if x != nil {
return x.Input
}
return ""
}
func (x *ToolCall) GetResult() string {
if x != nil {
return x.Result
}
return ""
}
var File_agent_proto_agent_proto protoreflect.FileDescriptor
const file_agent_proto_agent_proto_rawDesc = "" +
"\n" +
"\x17agent/proto/agent.proto\x12\x05agent\"D\n" +
"\vChatRequest\x12\x18\n" +
"\amessage\x18\x01 \x01(\tR\amessage\x12\x1b\n" +
"\tparent_id\x18\x02 \x01(\tR\bparentId\"\x9e\x01\n" +
"\fChatResponse\x12\x14\n" +
"\x05reply\x18\x01 \x01(\tR\x05reply\x12\x14\n" +
"\x05agent\x18\x02 \x01(\tR\x05agent\x12.\n" +
"\n" +
"tool_calls\x18\x03 \x03(\v2\x0f.agent.ToolCallR\ttoolCalls\x12\x15\n" +
"\x06run_id\x18\x04 \x01(\tR\x05runId\x12\x1b\n" +
"\tparent_id\x18\x05 \x01(\tR\bparentId\"\\\n" +
"\bToolCall\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x14\n" +
"\x05input\x18\x03 \x01(\tR\x05input\x12\x16\n" +
"\x06result\x18\x04 \x01(\tR\x06result2:\n" +
"\x05Agent\x121\n" +
"\x04Chat\x12\x12.agent.ChatRequest\x1a\x13.agent.ChatResponse\"\x00B\x0fZ\r./proto;agentb\x06proto3"
var (
file_agent_proto_agent_proto_rawDescOnce sync.Once
file_agent_proto_agent_proto_rawDescData []byte
)
func file_agent_proto_agent_proto_rawDescGZIP() []byte {
file_agent_proto_agent_proto_rawDescOnce.Do(func() {
file_agent_proto_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_agent_proto_agent_proto_rawDesc), len(file_agent_proto_agent_proto_rawDesc)))
})
return file_agent_proto_agent_proto_rawDescData
}
var file_agent_proto_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
var file_agent_proto_agent_proto_goTypes = []any{
(*ChatRequest)(nil), // 0: agent.ChatRequest
(*ChatResponse)(nil), // 1: agent.ChatResponse
(*ToolCall)(nil), // 2: agent.ToolCall
}
var file_agent_proto_agent_proto_depIdxs = []int32{
2, // 0: agent.ChatResponse.tool_calls:type_name -> agent.ToolCall
0, // 1: agent.Agent.Chat:input_type -> agent.ChatRequest
1, // 2: agent.Agent.Chat:output_type -> agent.ChatResponse
2, // [2:3] is the sub-list for method output_type
1, // [1:2] is the sub-list for method input_type
1, // [1:1] is the sub-list for extension type_name
1, // [1:1] is the sub-list for extension extendee
0, // [0:1] is the sub-list for field type_name
}
func init() { file_agent_proto_agent_proto_init() }
func file_agent_proto_agent_proto_init() {
if File_agent_proto_agent_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_agent_proto_agent_proto_rawDesc), len(file_agent_proto_agent_proto_rawDesc)),
NumEnums: 0,
NumMessages: 3,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_agent_proto_agent_proto_goTypes,
DependencyIndexes: file_agent_proto_agent_proto_depIdxs,
MessageInfos: file_agent_proto_agent_proto_msgTypes,
}.Build()
File_agent_proto_agent_proto = out.File
file_agent_proto_agent_proto_goTypes = nil
file_agent_proto_agent_proto_depIdxs = nil
}
-79
View File
@@ -1,79 +0,0 @@
// Code generated by protoc-gen-micro. DO NOT EDIT.
// source: agent/proto/agent.proto
package agent
import (
fmt "fmt"
proto "google.golang.org/protobuf/proto"
math "math"
)
import (
context "context"
client "go-micro.dev/v6/client"
server "go-micro.dev/v6/server"
)
// Reference imports to suppress errors if they are not otherwise used.
var _ = proto.Marshal
var _ = fmt.Errorf
var _ = math.Inf
// Reference imports to suppress errors if they are not otherwise used.
var _ context.Context
var _ client.Option
var _ server.Option
// Client API for Agent service
type AgentService interface {
Chat(ctx context.Context, in *ChatRequest, opts ...client.CallOption) (*ChatResponse, error)
}
type agentService struct {
c client.Client
name string
}
func NewAgentService(name string, c client.Client) AgentService {
return &agentService{
c: c,
name: name,
}
}
func (c *agentService) Chat(ctx context.Context, in *ChatRequest, opts ...client.CallOption) (*ChatResponse, error) {
req := c.c.NewRequest(c.name, "Agent.Chat", in)
out := new(ChatResponse)
err := c.c.Call(ctx, req, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
// Server API for Agent service
type AgentHandler interface {
Chat(context.Context, *ChatRequest, *ChatResponse) error
}
func RegisterAgentHandler(s server.Server, hdlr AgentHandler, opts ...server.HandlerOption) error {
type agent interface {
Chat(ctx context.Context, in *ChatRequest, out *ChatResponse) error
}
type Agent struct {
agent
}
h := &agentHandler{hdlr}
return s.Handle(s.NewHandler(&Agent{h}, opts...))
}
type agentHandler struct {
AgentHandler
}
func (h *agentHandler) Chat(ctx context.Context, in *ChatRequest, out *ChatResponse) error {
return h.AgentHandler.Chat(ctx, in, out)
}
-35
View File
@@ -1,35 +0,0 @@
syntax = "proto3";
package agent;
option go_package = "./proto;agent";
// Agent is the RPC interface for an AI agent.
service Agent {
rpc Chat(ChatRequest) returns (ChatResponse) {}
}
message ChatRequest {
string message = 1;
// parent_id correlates this chat with the workflow or agent run that dispatched it.
string parent_id = 2;
}
message ChatResponse {
string reply = 1;
string agent = 2;
repeated ToolCall tool_calls = 3;
// run_id correlates this chat response with tool calls, traces, and run history.
string run_id = 4;
// parent_id is set when this response belongs to a delegated sub-agent run.
string parent_id = 5;
}
message ToolCall {
string id = 1;
string name = 2;
string input = 3;
string result = 4;
}
+118
View File
@@ -0,0 +1,118 @@
// Code generated by protoc-gen-micro. DO NOT EDIT.
// source: github.com/micro/go-bot/proto/bot.proto
/*
Package go_micro_bot is a generated protocol buffer package.
It is generated from these files:
github.com/micro/go-bot/proto/bot.proto
It has these top-level messages:
HelpRequest
HelpResponse
ExecRequest
ExecResponse
*/
package go_micro_bot
import proto "github.com/golang/protobuf/proto"
import fmt "fmt"
import math "math"
import (
context "context"
client "github.com/micro/go-micro/client"
server "github.com/micro/go-micro/server"
)
// Reference imports to suppress errors if they are not otherwise used.
var _ = proto.Marshal
var _ = fmt.Errorf
var _ = math.Inf
// This is a compile-time assertion to ensure that this generated file
// is compatible with the proto package it is being compiled against.
// A compilation error at this line likely means your copy of the
// proto package needs to be updated.
const _ = proto.ProtoPackageIsVersion2 // please upgrade the proto package
// Reference imports to suppress errors if they are not otherwise used.
var _ context.Context
var _ client.Option
var _ server.Option
// Client API for Command service
type CommandService interface {
Help(ctx context.Context, in *HelpRequest, opts ...client.CallOption) (*HelpResponse, error)
Exec(ctx context.Context, in *ExecRequest, opts ...client.CallOption) (*ExecResponse, error)
}
type commandService struct {
c client.Client
name string
}
func NewCommandService(name string, c client.Client) CommandService {
if c == nil {
c = client.NewClient()
}
if len(name) == 0 {
name = "go.micro.bot"
}
return &commandService{
c: c,
name: name,
}
}
func (c *commandService) Help(ctx context.Context, in *HelpRequest, opts ...client.CallOption) (*HelpResponse, error) {
req := c.c.NewRequest(c.name, "Command.Help", in)
out := new(HelpResponse)
err := c.c.Call(ctx, req, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *commandService) Exec(ctx context.Context, in *ExecRequest, opts ...client.CallOption) (*ExecResponse, error) {
req := c.c.NewRequest(c.name, "Command.Exec", in)
out := new(ExecResponse)
err := c.c.Call(ctx, req, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
// Server API for Command service
type CommandHandler interface {
Help(context.Context, *HelpRequest, *HelpResponse) error
Exec(context.Context, *ExecRequest, *ExecResponse) error
}
func RegisterCommandHandler(s server.Server, hdlr CommandHandler, opts ...server.HandlerOption) error {
type _command interface {
Help(ctx context.Context, in *HelpRequest, out *HelpResponse) error
Exec(ctx context.Context, in *ExecRequest, out *ExecResponse) error
}
type Command struct {
_command
}
h := &commandHandler{hdlr}
return s.Handle(s.NewHandler(&Command{h}, opts...))
}
type commandHandler struct {
CommandHandler
}
func (h *commandHandler) Help(ctx context.Context, in *HelpRequest, out *HelpResponse) error {
return h.CommandHandler.Help(ctx, in, out)
}
func (h *commandHandler) Exec(ctx context.Context, in *ExecRequest, out *ExecResponse) error {
return h.CommandHandler.Exec(ctx, in, out)
}
+210
View File
@@ -0,0 +1,210 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// source: github.com/micro/go-bot/proto/bot.proto
package go_micro_bot
import proto "github.com/golang/protobuf/proto"
import fmt "fmt"
import math "math"
// Reference imports to suppress errors if they are not otherwise used.
var _ = proto.Marshal
var _ = fmt.Errorf
var _ = math.Inf
// This is a compile-time assertion to ensure that this generated file
// is compatible with the proto package it is being compiled against.
// A compilation error at this line likely means your copy of the
// proto package needs to be updated.
const _ = proto.ProtoPackageIsVersion2 // please upgrade the proto package
type HelpRequest struct {
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *HelpRequest) Reset() { *m = HelpRequest{} }
func (m *HelpRequest) String() string { return proto.CompactTextString(m) }
func (*HelpRequest) ProtoMessage() {}
func (*HelpRequest) Descriptor() ([]byte, []int) {
return fileDescriptor_bot_654832eab83ed4b5, []int{0}
}
func (m *HelpRequest) XXX_Unmarshal(b []byte) error {
return xxx_messageInfo_HelpRequest.Unmarshal(m, b)
}
func (m *HelpRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
return xxx_messageInfo_HelpRequest.Marshal(b, m, deterministic)
}
func (dst *HelpRequest) XXX_Merge(src proto.Message) {
xxx_messageInfo_HelpRequest.Merge(dst, src)
}
func (m *HelpRequest) XXX_Size() int {
return xxx_messageInfo_HelpRequest.Size(m)
}
func (m *HelpRequest) XXX_DiscardUnknown() {
xxx_messageInfo_HelpRequest.DiscardUnknown(m)
}
var xxx_messageInfo_HelpRequest proto.InternalMessageInfo
type HelpResponse struct {
Usage string `protobuf:"bytes,1,opt,name=usage,proto3" json:"usage,omitempty"`
Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *HelpResponse) Reset() { *m = HelpResponse{} }
func (m *HelpResponse) String() string { return proto.CompactTextString(m) }
func (*HelpResponse) ProtoMessage() {}
func (*HelpResponse) Descriptor() ([]byte, []int) {
return fileDescriptor_bot_654832eab83ed4b5, []int{1}
}
func (m *HelpResponse) XXX_Unmarshal(b []byte) error {
return xxx_messageInfo_HelpResponse.Unmarshal(m, b)
}
func (m *HelpResponse) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
return xxx_messageInfo_HelpResponse.Marshal(b, m, deterministic)
}
func (dst *HelpResponse) XXX_Merge(src proto.Message) {
xxx_messageInfo_HelpResponse.Merge(dst, src)
}
func (m *HelpResponse) XXX_Size() int {
return xxx_messageInfo_HelpResponse.Size(m)
}
func (m *HelpResponse) XXX_DiscardUnknown() {
xxx_messageInfo_HelpResponse.DiscardUnknown(m)
}
var xxx_messageInfo_HelpResponse proto.InternalMessageInfo
func (m *HelpResponse) GetUsage() string {
if m != nil {
return m.Usage
}
return ""
}
func (m *HelpResponse) GetDescription() string {
if m != nil {
return m.Description
}
return ""
}
type ExecRequest struct {
Args []string `protobuf:"bytes,1,rep,name=args,proto3" json:"args,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *ExecRequest) Reset() { *m = ExecRequest{} }
func (m *ExecRequest) String() string { return proto.CompactTextString(m) }
func (*ExecRequest) ProtoMessage() {}
func (*ExecRequest) Descriptor() ([]byte, []int) {
return fileDescriptor_bot_654832eab83ed4b5, []int{2}
}
func (m *ExecRequest) XXX_Unmarshal(b []byte) error {
return xxx_messageInfo_ExecRequest.Unmarshal(m, b)
}
func (m *ExecRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
return xxx_messageInfo_ExecRequest.Marshal(b, m, deterministic)
}
func (dst *ExecRequest) XXX_Merge(src proto.Message) {
xxx_messageInfo_ExecRequest.Merge(dst, src)
}
func (m *ExecRequest) XXX_Size() int {
return xxx_messageInfo_ExecRequest.Size(m)
}
func (m *ExecRequest) XXX_DiscardUnknown() {
xxx_messageInfo_ExecRequest.DiscardUnknown(m)
}
var xxx_messageInfo_ExecRequest proto.InternalMessageInfo
func (m *ExecRequest) GetArgs() []string {
if m != nil {
return m.Args
}
return nil
}
type ExecResponse struct {
Result []byte `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"`
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *ExecResponse) Reset() { *m = ExecResponse{} }
func (m *ExecResponse) String() string { return proto.CompactTextString(m) }
func (*ExecResponse) ProtoMessage() {}
func (*ExecResponse) Descriptor() ([]byte, []int) {
return fileDescriptor_bot_654832eab83ed4b5, []int{3}
}
func (m *ExecResponse) XXX_Unmarshal(b []byte) error {
return xxx_messageInfo_ExecResponse.Unmarshal(m, b)
}
func (m *ExecResponse) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
return xxx_messageInfo_ExecResponse.Marshal(b, m, deterministic)
}
func (dst *ExecResponse) XXX_Merge(src proto.Message) {
xxx_messageInfo_ExecResponse.Merge(dst, src)
}
func (m *ExecResponse) XXX_Size() int {
return xxx_messageInfo_ExecResponse.Size(m)
}
func (m *ExecResponse) XXX_DiscardUnknown() {
xxx_messageInfo_ExecResponse.DiscardUnknown(m)
}
var xxx_messageInfo_ExecResponse proto.InternalMessageInfo
func (m *ExecResponse) GetResult() []byte {
if m != nil {
return m.Result
}
return nil
}
func (m *ExecResponse) GetError() string {
if m != nil {
return m.Error
}
return ""
}
func init() {
proto.RegisterType((*HelpRequest)(nil), "go.micro.bot.HelpRequest")
proto.RegisterType((*HelpResponse)(nil), "go.micro.bot.HelpResponse")
proto.RegisterType((*ExecRequest)(nil), "go.micro.bot.ExecRequest")
proto.RegisterType((*ExecResponse)(nil), "go.micro.bot.ExecResponse")
}
func init() {
proto.RegisterFile("github.com/micro/go-bot/proto/bot.proto", fileDescriptor_bot_654832eab83ed4b5)
}
var fileDescriptor_bot_654832eab83ed4b5 = []byte{
// 246 bytes of a gzipped FileDescriptorProto
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x6c, 0x90, 0x41, 0x4b, 0xc4, 0x30,
0x10, 0x85, 0xb7, 0xba, 0xae, 0xec, 0xb4, 0x5e, 0x82, 0x48, 0xdd, 0x53, 0xcd, 0xc5, 0xbd, 0x98,
0x82, 0x5e, 0x05, 0x0f, 0xa2, 0x78, 0xee, 0x3f, 0x68, 0xba, 0x43, 0x2c, 0x6c, 0x3b, 0x35, 0x99,
0x82, 0xff, 0xc1, 0x3f, 0x2d, 0x4d, 0x72, 0x08, 0xcb, 0xde, 0xe6, 0x65, 0x86, 0xf7, 0xbe, 0x17,
0x78, 0x34, 0x3d, 0x7f, 0xcf, 0x5a, 0x75, 0x34, 0xd4, 0x43, 0xdf, 0x59, 0xaa, 0x0d, 0x3d, 0x69,
0xe2, 0x7a, 0xb2, 0xc4, 0x54, 0x6b, 0x62, 0xe5, 0x27, 0x51, 0x18, 0x52, 0xfe, 0x40, 0x69, 0x62,
0x79, 0x03, 0xf9, 0x17, 0x1e, 0xa7, 0x06, 0x7f, 0x66, 0x74, 0x2c, 0x3f, 0xa1, 0x08, 0xd2, 0x4d,
0x34, 0x3a, 0x14, 0xb7, 0x70, 0x35, 0xbb, 0xd6, 0x60, 0x99, 0x55, 0xd9, 0x7e, 0xdb, 0x04, 0x21,
0x2a, 0xc8, 0x0f, 0xe8, 0x3a, 0xdb, 0x4f, 0xdc, 0xd3, 0x58, 0x5e, 0xf8, 0x5d, 0xfa, 0x24, 0x1f,
0x20, 0xff, 0xf8, 0xc5, 0x2e, 0xda, 0x0a, 0x01, 0xeb, 0xd6, 0x1a, 0x57, 0x66, 0xd5, 0xe5, 0x7e,
0xdb, 0xf8, 0x59, 0xbe, 0x42, 0x11, 0x4e, 0x62, 0xd4, 0x1d, 0x6c, 0x2c, 0xba, 0xf9, 0xc8, 0x3e,
0xab, 0x68, 0xa2, 0x5a, 0x10, 0xd0, 0x5a, 0xb2, 0x31, 0x26, 0x88, 0xe7, 0xbf, 0x0c, 0xae, 0xdf,
0x69, 0x18, 0xda, 0xf1, 0x20, 0xde, 0x60, 0xbd, 0x40, 0x8b, 0x7b, 0x95, 0x56, 0x53, 0x49, 0xaf,
0xdd, 0xee, 0xdc, 0x2a, 0x04, 0xcb, 0xd5, 0x62, 0xb0, 0xa0, 0x9c, 0x1a, 0x24, 0x0d, 0x4e, 0x0d,
0x52, 0x72, 0xb9, 0xd2, 0x1b, 0xff, 0xb5, 0x2f, 0xff, 0x01, 0x00, 0x00, 0xff, 0xff, 0xcb, 0x77,
0xdf, 0x28, 0x85, 0x01, 0x00, 0x00,
}
+25
View File
@@ -0,0 +1,25 @@
syntax = "proto3";
package go.micro.bot;
service Command {
rpc Help(HelpRequest) returns (HelpResponse) {};
rpc Exec(ExecRequest) returns (ExecResponse) {};
}
message HelpRequest {
}
message HelpResponse {
string usage = 1;
string description = 2;
}
message ExecRequest {
repeated string args = 1;
}
message ExecResponse {
bytes result = 1;
string error = 2;
}
-283
View File
@@ -1,283 +0,0 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/store"
)
func TestAskCancellationAbortsPromptly(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
<-ctx.Done()
return nil, ctx.Err()
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("cancel"), ModelCallTimeout(time.Second), ModelRetry(3, time.Millisecond))
ctx, cancel := context.WithCancel(context.Background())
cancel()
start := time.Now()
_, err := a.Ask(ctx, "stop")
if !errors.Is(err, context.Canceled) {
t.Fatalf("Ask error = %v, want context canceled", err)
}
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
t.Fatalf("Ask took %s after cancellation, want prompt abort", elapsed)
}
}
func TestAskRetriesTransientErrorsThenSucceeds(t *testing.T) {
attempts := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
attempts++
if attempts < 3 {
return nil, context.DeadlineExceeded
}
return &ai.Response{Reply: "ok"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("retry-success"), ModelRetry(3, time.Millisecond))
resp, err := a.Ask(context.Background(), "hello")
if err != nil {
t.Fatalf("Ask returned error: %v", err)
}
if resp.Reply != "ok" {
t.Fatalf("reply = %q, want ok", resp.Reply)
}
if attempts != 3 {
t.Fatalf("attempts = %d, want 3", attempts)
}
}
func TestAskRetriesTransientErrorsThenSurfacesStructuredError(t *testing.T) {
attempts := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
attempts++
return nil, context.DeadlineExceeded
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("retry-fail"), ModelRetry(2, time.Millisecond))
_, err := a.Ask(context.Background(), "hello")
var retryErr *ai.RetryError
if !errors.As(err, &retryErr) {
t.Fatalf("Ask error = %T %v, want *ai.RetryError", err, err)
}
if retryErr.Attempts != 2 {
t.Fatalf("retry attempts = %d, want 2", retryErr.Attempts)
}
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 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) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
t.Fatal("missing tool handler")
}
canceled, cancel := context.WithCancel(ctx)
cancel()
res := opts.ToolHandler(canceled, ai.ToolCall{ID: "call-1", Name: toolPlan, Input: map[string]any{
"steps": []any{map[string]any{"task": "should not persist", "status": "pending"}},
}})
if !strings.Contains(res.Content, context.Canceled.Error()) {
t.Fatalf("tool result = %q, want cancellation error", res.Content)
}
return &ai.Response{Reply: "ok"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("cancel-tools"))
if _, err := a.Ask(context.Background(), "try a canceled tool"); err != nil {
t.Fatalf("Ask: %v", err)
}
if plan := a.loadPlan(); plan != "" {
t.Fatalf("plan persisted after canceled tool context: %q", plan)
}
}
func TestToolCallTimeoutPropagatesDeadlineToCustomTool(t *testing.T) {
var sawDeadline bool
a := newTestAgent(
Name("tool-timeout"),
ToolCallTimeout(10*time.Millisecond),
WithTool("slow", "slow tool", nil, func(ctx context.Context, input map[string]any) (string, error) {
if _, ok := ctx.Deadline(); ok {
sawDeadline = true
}
<-ctx.Done()
return "", ctx.Err()
}),
)
start := time.Now()
content := toolContent(a.toolHandler(), "slow", nil)
if !sawDeadline {
t.Fatal("custom tool did not receive a deadline")
}
if !strings.Contains(content, context.DeadlineExceeded.Error()) {
t.Fatalf("tool result = %q, want deadline exceeded", content)
}
if elapsed := time.Since(start); elapsed > 200*time.Millisecond {
t.Fatalf("tool call took %s, want bounded timeout", elapsed)
}
}
func TestAskCancellationDuringToolCallFailsRun(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
t.Fatal("missing tool handler")
}
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "cancel-self"})
if !strings.Contains(res.Content, context.Canceled.Error()) {
t.Fatalf("tool result = %q, want cancellation error", res.Content)
}
return &ai.Response{Reply: "should not succeed"}, nil
}
defer func() { fakeGen = nil }()
ctx, cancel := context.WithCancel(context.Background())
a := newTestAgent(
Name("cancel-during-tool"),
WithTool("cancel-self", "cancel the run context", nil, func(context.Context, map[string]any) (string, error) {
cancel()
return "", context.Canceled
}),
)
_, err := a.Ask(ctx, "cancel during tool")
if !errors.Is(err, context.Canceled) {
t.Fatalf("Ask error = %v, want context canceled", err)
}
}
func TestAskCheckpointRecordsTerminalOperationalFailureStatus(t *testing.T) {
tests := []struct {
name string
err error
want string
}{
{name: "canceled", err: context.Canceled, want: "canceled"},
{name: "timeout", err: context.DeadlineExceeded, want: "timeout"},
{name: "rate limited", err: testStatusError{code: 429}, want: "rate_limited"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "terminal-"+strings.ReplaceAll(tt.name, " ", "-"))
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
return nil, tt.err
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("terminal-"+strings.ReplaceAll(tt.name, " ", "-")), WithCheckpoint(cp))
_, err := a.Ask(context.Background(), "fail safely")
if err == nil {
t.Fatal("Ask succeeded, want failure")
}
runs, err := cp.List(context.Background())
if err != nil {
t.Fatalf("List: %v", err)
}
if len(runs) != 1 {
t.Fatalf("checkpointed runs = %d, want 1", len(runs))
}
if runs[0].Status != tt.want {
t.Fatalf("run status = %q, want %q", runs[0].Status, tt.want)
}
if len(runs[0].Steps) == 0 || runs[0].Steps[0].Status != tt.want {
t.Fatalf("step status = %#v, want %q", runs[0].Steps, tt.want)
}
if pending, err := Pending(context.Background(), a); err != nil || len(pending) != 0 {
t.Fatalf("Pending = %#v, %v; want no terminal run", pending, err)
}
})
}
}
type testStatusError struct {
code int
}
func (e testStatusError) Error() string { return "provider status error" }
func (e testStatusError) StatusCode() int { return e.code }
func TestToolRetryRetriesTransientToolErrorsThenSucceeds(t *testing.T) {
attempts := 0
a := newTestAgent(
Name("tool-retry-success"),
ToolRetry(3, time.Millisecond),
WithTool("flaky", "flaky tool", nil, func(context.Context, map[string]any) (string, error) {
attempts++
if attempts < 3 {
return "", context.DeadlineExceeded
}
return "ok", nil
}),
)
content := toolContent(a.toolHandler(), "flaky", nil)
if content != "ok" {
t.Fatalf("tool result = %q, want ok", content)
}
if attempts != 3 {
t.Fatalf("attempts = %d, want 3", attempts)
}
}
func TestToolRetryDoesNotRetryGuardrailRefusals(t *testing.T) {
attempts := 0
a := newTestAgent(
Name("tool-retry-refusal"),
MaxSteps(1),
ToolRetry(3, time.Millisecond),
WithTool("counted", "counted tool", nil, func(context.Context, map[string]any) (string, error) {
attempts++
return "ok", nil
}),
)
h := a.toolHandler()
_ = toolContent(h, "counted", nil)
content := toolContent(h, "counted", nil)
if !strings.Contains(content, "step limit reached") {
t.Fatalf("tool result = %q, want step-limit refusal", content)
}
if attempts != 1 {
t.Fatalf("attempts = %d, want only the allowed tool call to execute", attempts)
}
}
-316
View File
@@ -1,316 +0,0 @@
package agent
import (
"context"
"encoding/json"
"errors"
"io"
"strings"
"sync"
"github.com/google/uuid"
"go-micro.dev/v6/ai"
)
// StreamEventType identifies an event emitted by a tool-aware agent stream.
type StreamEventType string
const (
// StreamEventToolStart is emitted immediately before a tool call runs.
StreamEventToolStart StreamEventType = "tool_start"
// StreamEventToolEnd is emitted after a tool call returns or is refused.
StreamEventToolEnd StreamEventType = "tool_end"
// StreamEventToken carries a chunk of the final answer.
StreamEventToken StreamEventType = "token"
// StreamEventDone carries the completed agent response.
StreamEventDone StreamEventType = "done"
)
// StreamEvent is one event from StreamAsk.
type StreamEvent struct {
Type StreamEventType
Token string
ToolCall ai.ToolCall
Result ai.ToolResult
Response *Response
}
// AgentStream is a stream of tool execution events followed by final-answer chunks.
type AgentStream interface {
Recv() (*StreamEvent, error)
Close() error
}
// StreamAsk runs an agent Ask turn with tool start/end events and streams the final answer.
// It is additive for callers that hold the public Agent interface; concrete agents also
// expose the same method directly.
func StreamAsk(ctx context.Context, ag Agent, message string) (AgentStream, error) {
streamer, ok := ag.(interface {
StreamAsk(context.Context, string) (AgentStream, error)
})
if !ok {
return nil, errors.New("agent: StreamAsk unsupported by implementation")
}
return streamer.StreamAsk(ctx, message)
}
// ResumeStreamAsk resumes a checkpointed agent run and emits the same event
// shape as StreamAsk. Completed runs are streamed from the persisted response;
// unfinished runs continue from their checkpoint and emit tool events for any
// work that still needs to run. Tool calls already recorded as done in the
// checkpoint are reused by the agent checkpoint wrapper and are not re-executed.
func ResumeStreamAsk(ctx context.Context, ag Agent, runID string) (AgentStream, error) {
a, ok := ag.(*agentImpl)
if !ok {
return nil, errors.New("agent: ResumeStreamAsk unsupported by implementation")
}
return a.resumeStreamAsk(ctx, runID)
}
// 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) {
events := make(chan *StreamEvent, 16)
done := make(chan struct{})
s := &agentStream{events: events, done: done}
go func() {
defer close(events)
defer close(done)
resp, err := a.askWithStreamEvents(ctx, message, events)
if err != nil {
s.setErr(err)
return
}
for _, tok := range splitStreamTokens(resp.Reply) {
if !sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToken, Token: tok}) {
return
}
}
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventDone, Response: resp})
}()
return s, nil
}
func (a *agentImpl) resumeStreamAsk(ctx context.Context, runID string) (AgentStream, error) {
events := make(chan *StreamEvent, 16)
done := make(chan struct{})
s := &agentStream{events: events, done: done}
go func() {
defer close(events)
defer close(done)
resp, err := a.resumeWithStreamEvents(ctx, runID, events)
if err != nil {
s.setErr(err)
return
}
for _, tok := range splitStreamTokens(resp.Reply) {
if !sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToken, Token: tok}) {
return
}
}
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventDone, Response: resp})
}()
return s, nil
}
func (a *agentImpl) askWithStreamEvents(ctx context.Context, message string, events chan<- *StreamEvent) (*Response, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.tools == nil {
a.tools = ai.NewTools(a.opts.Registry, ai.ToolClient(a.opts.Client))
}
base := a.toolHandler()
handler := func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToolStart, ToolCall: call})
result := base(ctx, call)
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToolEnd, ToolCall: call, Result: result})
return result
}
a.setupWithToolHandler(handler)
defer a.setupWithToolHandler(nil)
return a.askLocked(ctx, uuid.New().String(), message, a.parentRunID, nil, true)
}
func (a *agentImpl) resumeWithStreamEvents(ctx context.Context, runID string, events chan<- *StreamEvent) (*Response, error) {
if a.opts.Checkpoint == nil {
return nil, errors.New("agent: ResumeStreamAsk requires a checkpoint")
}
run, ok, err := a.opts.Checkpoint.Load(ctx, runID)
if err != nil {
return nil, err
}
if !ok {
return nil, errors.New("agent: checkpointed run not found")
}
if run.Status == "done" {
var resp Response
if err := json.Unmarshal(run.State.Data, &resp); err != nil {
return nil, err
}
return &resp, nil
}
if terminalAgentRunStatus(run.Status) {
return nil, errors.New("agent: checkpointed run is terminal with status " + run.Status)
}
a.mu.Lock()
defer a.mu.Unlock()
if a.tools == nil {
a.tools = ai.NewTools(a.opts.Registry, ai.ToolClient(a.opts.Client))
}
base := a.toolHandler()
handler := func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToolStart, ToolCall: call})
result := base(ctx, call)
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToolEnd, ToolCall: call, Result: result})
return result
}
a.setupWithToolHandler(handler)
defer a.setupWithToolHandler(nil)
if run.Status == "paused" {
if run.State.Stage == agentInputStep {
return nil, errors.New("agent: checkpointed run is input-required; resume with ResumeInput")
}
run.Status = "running"
run.State.Stage = agentAskStep
}
return a.askLocked(ctx, run.ID, string(run.State.Data), run.ParentID, &run, false)
}
type agentStreamAdapter struct {
stream AgentStream
}
type memoryRecordingStream struct {
stream ai.Stream
memory Memory
mu sync.Mutex
chunks []string
closed bool
}
func (s *memoryRecordingStream) Recv() (*ai.Response, error) {
resp, err := s.stream.Recv()
if resp != nil && resp.Reply != "" {
s.mu.Lock()
s.chunks = append(s.chunks, resp.Reply)
s.mu.Unlock()
}
if errors.Is(err, io.EOF) {
s.recordAssistant()
}
return resp, err
}
func (s *memoryRecordingStream) Close() error {
s.recordAssistant()
return s.stream.Close()
}
func (s *memoryRecordingStream) recordAssistant() {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return
}
s.closed = true
if reply := strings.Join(s.chunks, ""); reply != "" {
s.memory.Add("assistant", reply)
}
}
func (s *agentStreamAdapter) Recv() (*ai.Response, error) {
for {
event, err := s.stream.Recv()
if err != nil {
return nil, err
}
if event == nil {
continue
}
switch event.Type {
case StreamEventToken:
if event.Token == "" {
continue
}
return &ai.Response{Reply: event.Token}, nil
case StreamEventDone:
return nil, io.EOF
}
}
}
func (s *agentStreamAdapter) Close() error {
return s.stream.Close()
}
func (a *agentImpl) streamAskAI(ctx context.Context, message string) (ai.Stream, error) {
stream, err := a.StreamAsk(ctx, message)
if err != nil {
return nil, err
}
return &agentStreamAdapter{stream: stream}, nil
}
type agentStream struct {
events <-chan *StreamEvent
done <-chan struct{}
mu sync.Mutex
err error
}
func (s *agentStream) Recv() (*StreamEvent, error) {
ev, ok := <-s.events
if ok {
return ev, nil
}
s.mu.Lock()
defer s.mu.Unlock()
if s.err != nil {
return nil, s.err
}
return nil, io.EOF
}
func (s *agentStream) Close() error {
<-s.done
return nil
}
func (s *agentStream) setErr(err error) {
s.mu.Lock()
defer s.mu.Unlock()
s.err = err
}
func sendStreamEvent(ctx context.Context, events chan<- *StreamEvent, ev *StreamEvent) bool {
select {
case events <- ev:
return true
case <-ctx.Done():
return false
}
}
func splitStreamTokens(reply string) []string {
if reply == "" {
return nil
}
parts := strings.Fields(reply)
if len(parts) == 0 {
return []string{reply}
}
out := make([]string, 0, len(parts))
for i, part := range parts {
if i > 0 {
part = " " + part
}
out = append(out, part)
}
return out
}
-221
View File
@@ -1,221 +0,0 @@
package agent
import (
"context"
"errors"
"io"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/store"
)
func TestStreamAskEmitsToolEventsAndFinalTokens(t *testing.T) {
calls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
t.Fatal("StreamAsk must configure a tool handler")
}
calls++
result := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "echo", Input: map[string]any{"text": "hello"}})
return &ai.Response{
Reply: "planning",
Answer: "final answer",
ToolCalls: []ai.ToolCall{{ID: "call-1", Name: "echo", Input: map[string]any{"text": "hello"}, Result: result.Content}},
}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("streamer"), WithTool("echo", "echo text", nil, func(ctx context.Context, input map[string]any) (string, error) {
return input["text"].(string), nil
}))
stream, err := a.StreamAsk(context.Background(), "say hello")
if err != nil {
t.Fatalf("StreamAsk: %v", err)
}
var types []StreamEventType
var tokens string
var done *Response
for {
event, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("Recv: %v", err)
}
types = append(types, event.Type)
if event.Type == StreamEventToken {
tokens += event.Token
}
if event.Type == StreamEventDone {
done = event.Response
}
}
want := []StreamEventType{StreamEventToolStart, StreamEventToolEnd, StreamEventToken, StreamEventToken, StreamEventToken, StreamEventDone}
if len(types) != len(want) {
t.Fatalf("event types = %v, want %v", types, want)
}
for i := range want {
if types[i] != want[i] {
t.Fatalf("event types = %v, want %v", types, want)
}
}
if tokens != "planning final answer" {
t.Fatalf("tokens = %q", tokens)
}
if done == nil || done.Reply != "planning\n\nfinal answer" {
t.Fatalf("done response = %#v", done)
}
if calls != 1 {
t.Fatalf("Generate calls = %d, want 1", calls)
}
}
func TestStreamAskHelperRejectsUnsupportedAgent(t *testing.T) {
_, err := StreamAsk(context.Background(), unsupportedAgent{}, "hello")
if err == nil {
t.Fatal("StreamAsk helper should reject unsupported implementations")
}
}
func TestAgentStreamUsesProviderStreamingAndRecordsAssistantMemory(t *testing.T) {
var sawRequest bool
fakeStream = func(ctx context.Context, opts ai.Options, req *ai.Request) (ai.Stream, error) {
sawRequest = true
if req.Prompt != "stream the answer" {
t.Fatalf("Prompt = %q, want stream the answer", req.Prompt)
}
if len(req.Messages) != 1 || req.Messages[0].Role != "user" || req.Messages[0].Content != "stream the answer" {
t.Fatalf("Messages = %#v, want current user turn in memory", req.Messages)
}
return &sliceStream{chunks: []string{"hel", "lo"}}, nil
}
defer func() { fakeStream = nil }()
a := newTestAgent(Name("provider-stream"))
stream, err := a.Stream(context.Background(), "stream the answer")
if err != nil {
t.Fatalf("Stream: %v", err)
}
var reply string
for {
chunk, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("Recv: %v", err)
}
reply += chunk.Reply
}
if err := stream.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if !sawRequest {
t.Fatal("provider Stream was not called")
}
if reply != "hello" {
t.Fatalf("reply = %q, want hello", reply)
}
got := a.mem.Messages()
if len(got) != 2 || got[0].Role != "user" || got[0].Content != "stream the answer" || got[1].Role != "assistant" || got[1].Content != "hello" {
t.Fatalf("memory = %#v, want user turn and streamed assistant reply", got)
}
}
func TestResumeStreamAskDoesNotReplayCompletedTool(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewStore(), "stream-resume-agent")
toolRuns := 0
first := true
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler != nil {
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "charge", Input: map[string]any{"order": "42"}})
if res.Content != "charged" {
t.Fatalf("tool result = %q, want charged", res.Content)
}
}
if first {
first = false
return nil, errors.New("stream disconnected after tool")
}
return &ai.Response{Reply: "finished from streamed checkpoint"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("stream-resume-agent"), WithCheckpoint(cp),
WithTool("charge", "charge once", nil, func(context.Context, map[string]any) (string, error) {
toolRuns++
return "charged", nil
}))
stream, err := a.StreamAsk(ctx, "charge order 42")
if err != nil {
t.Fatalf("StreamAsk: %v", err)
}
for {
_, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
break
}
}
if toolRuns != 1 {
t.Fatalf("tool executions after failed StreamAsk = %d, want 1", toolRuns)
}
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 {
t.Fatalf("Pending returned %d runs, want 1", len(runs))
}
resumed, err := ResumeStreamAsk(ctx, a, runs[0].ID)
if err != nil {
t.Fatalf("ResumeStreamAsk: %v", err)
}
var toolEvents int
var done *Response
for {
event, err := resumed.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("resumed Recv: %v", err)
}
if event.Type == StreamEventToolStart || event.Type == StreamEventToolEnd {
toolEvents++
}
if event.Type == StreamEventDone {
done = event.Response
}
}
if toolRuns != 1 {
t.Fatalf("tool executions after ResumeStreamAsk = %d, want completed tool was not replayed", toolRuns)
}
if toolEvents != 2 {
t.Fatalf("resumed tool events = %d, want start/end for replayed checkpoint result", toolEvents)
}
if done == nil || done.Reply != "finished from streamed checkpoint" || done.RunID != runs[0].ID {
t.Fatalf("done response = %#v", done)
}
}
type unsupportedAgent struct{}
func (unsupportedAgent) Name() string { return "unsupported" }
func (unsupportedAgent) Init(...Option) {}
func (unsupportedAgent) Options() Options { return Options{} }
func (unsupportedAgent) Ask(context.Context, string) (*Response, error) { return nil, nil }
func (unsupportedAgent) Stream(context.Context, string) (ai.Stream, error) { return nil, nil }
func (unsupportedAgent) Run() error { return nil }
func (unsupportedAgent) Stop() error { return nil }
func (unsupportedAgent) String() string { return "unsupported" }
-141
View File
@@ -1,141 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
"go-micro.dev/v6/ai"
)
var fencedJSONBlock = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
type textToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Tool string `json:"tool"`
Input map[string]any `json:"input"`
Arguments map[string]any `json:"arguments"`
}
// executeTextToolCalls is a compatibility fallback for providers that return a
// tool call as text JSON instead of a structured tool_calls field. It only runs
// calls whose names match the tools offered to the model, so ordinary JSON
// answers are left untouched.
func (a *agentImpl) executeTextToolCalls(ctx context.Context, reply string, tools []ai.Tool) ([]ai.ToolCall, string, bool) {
calls := parseTextToolCalls(reply, tools)
if len(calls) == 0 {
return nil, "", false
}
handler := a.toolHandler()
results := make([]string, 0, len(calls))
for i := range calls {
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)
}
}
return calls, strings.Join(results, "\n"), true
}
func parseTextToolCalls(text string, tools []ai.Tool) []ai.ToolCall {
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
}
for _, candidate := range jsonCandidates(text) {
if calls := decodeTextToolCalls(candidate, allowed); len(calls) > 0 {
return calls
}
}
return nil
}
func jsonCandidates(text string) []string {
trimmed := strings.TrimSpace(text)
var out []string
if trimmed != "" {
out = append(out, trimmed)
}
for _, match := range fencedJSONBlock.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]bool) []ai.ToolCall {
var root any
if err := json.Unmarshal([]byte(candidate), &root); err != nil {
return nil
}
return collectTextToolCalls(root, allowed)
}
func collectTextToolCalls(v any, allowed map[string]bool) []ai.ToolCall {
switch x := v.(type) {
case []any:
var out []ai.ToolCall
for _, item := range x {
out = append(out, collectTextToolCalls(item, allowed)...)
}
return out
case map[string]any:
if nested, ok := firstNestedToolCalls(x); ok {
return collectTextToolCalls(nested, allowed)
}
call := mapToTextToolCall(x)
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: name, Input: input}}
default:
return nil
}
}
func firstNestedToolCalls(m map[string]any) (any, bool) {
for _, key := range []string{"tool_calls", "toolCalls", "calls"} {
if v, ok := m[key]; ok {
return v, true
}
}
return nil, false
}
func mapToTextToolCall(m map[string]any) textToolCall {
b, _ := json.Marshal(m)
var call textToolCall
_ = json.Unmarshal(b, &call)
return call
}
-183
View File
@@ -1,183 +0,0 @@
package agent
import (
"context"
"fmt"
"strings"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
// A registered wrapper runs around every tool call and can observe and
// modify the result.
func TestWrapToolWraps(t *testing.T) {
var saw string
wrap := func(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
saw = call.Name
res := next(ctx, call)
res.Content = "wrapped:" + res.Content
return res
}
}
a := newTestAgent(Name("wrapped"), WrapTool(wrap))
content := toolContent(a.toolHandler(), "demo_Svc_Do", map[string]any{})
if saw != "demo_Svc_Do" {
t.Errorf("wrapper saw %q, want demo_Svc_Do", saw)
}
if !strings.HasPrefix(content, "wrapped:") {
t.Errorf("wrapper did not modify the result; got %q", content)
}
}
// Multiple wrappers compose outermost-first: the first registered wrapper
// is the outer layer, so it runs first on the way in and last on the way
// out.
func TestWrapToolOrder(t *testing.T) {
var order []string
mk := func(tag string) ai.ToolWrapper {
return func(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
order = append(order, "in:"+tag)
res := next(ctx, call)
order = append(order, "out:"+tag)
return res
}
}
}
a := newTestAgent(Name("ordered"), WrapTool(mk("a"), mk("b")))
toolContent(a.toolHandler(), "demo_Svc_Do", map[string]any{})
want := "in:a in:b out:b out:a"
if got := strings.Join(order, " "); got != want {
t.Errorf("wrapper order = %q, want %q", got, want)
}
}
// Wrappers run outside the built-in guardrails, so they observe a refused
// call and its refusal result rather than being short-circuited.
func TestWrapToolSeesGuardrailRefusal(t *testing.T) {
var sawResult string
wrap := func(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
res := next(ctx, call)
sawResult = res.Content
return res
}
}
a := newTestAgent(Name("gated-wrap"),
ApproveTool(func(tool string, input map[string]any) (bool, string) {
return false, "denied"
}),
WrapTool(wrap),
)
toolContent(a.toolHandler(), "demo_Svc_Do", map[string]any{})
if !strings.Contains(sawResult, "not approved") {
t.Errorf("wrapper should observe the guardrail refusal; got %q", sawResult)
}
}
// A guardrail refusal carries a structured reason a wrapper can switch on,
// so reliability tooling (e.g. loop handling) needn't parse the message.
func TestWrapToolSeesRefusedReason(t *testing.T) {
a := newTestAgent(Name("looper"), LoopLimit(2))
h := a.toolHandler()
var last ai.ToolResult
for i := 0; i < 3; i++ {
last = h(context.Background(), ai.ToolCall{ID: "x", Name: "demo_Svc_Do", Input: map[string]any{"q": "same"}})
}
if last.Refused != ai.RefusedLoop {
t.Errorf("Refused = %q, want %q", last.Refused, ai.RefusedLoop)
}
}
// ctxMock is a model that forwards the Generate context to the tool
// handler (as real providers do), so a wrapper can read ai.RunInfo.
type ctxMock struct{ opts ai.Options }
func (m *ctxMock) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&m.opts)
}
return nil
}
func (m *ctxMock) Options() ai.Options { return m.opts }
func (m *ctxMock) String() string { return "ctxmock" }
func (m *ctxMock) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("no stream")
}
func (m *ctxMock) Generate(ctx context.Context, _ *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) {
if m.opts.ToolHandler != nil {
m.opts.ToolHandler(ctx, ai.ToolCall{ID: "c1", Name: "demo_Svc_Do", Input: map[string]any{}})
}
return &ai.Response{Answer: "done"}, nil
}
// During an Ask, a wrapper sees RunInfo on the context: a correlation id
// for the run and the agent's name.
func TestWrapToolSeesRunInfo(t *testing.T) {
ai.Register("ctxmock", func(opts ...ai.Option) ai.Model {
m := &ctxMock{}
_ = m.Init(opts...)
return m
})
var got ai.RunInfo
var ok bool
a := New(
Name("runner"),
Provider("ctxmock"),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WrapTool(func(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
got, ok = ai.RunInfoFrom(ctx)
return next(ctx, call)
}
}),
)
resp, err := a.Ask(context.Background(), "go")
if err != nil {
t.Fatalf("Ask: %v", err)
}
if !ok {
t.Fatal("wrapper did not see RunInfo on the context")
}
if got.Agent != "runner" {
t.Errorf("RunInfo.Agent = %q, want runner", got.Agent)
}
if got.RunID == "" {
t.Error("RunInfo.RunID is empty")
}
if resp.RunID != got.RunID {
t.Errorf("Response.RunID = %q, want wrapper RunID %q", resp.RunID, got.RunID)
}
if resp.ParentID != "" {
t.Errorf("Response.ParentID = %q, want empty", resp.ParentID)
}
}
// call.Scan decodes a tool call's input into a typed struct.
func TestToolCallScan(t *testing.T) {
call := ai.ToolCall{Input: map[string]any{"query": "hello", "limit": 5}}
var args struct {
Query string `json:"query"`
Limit int `json:"limit"`
}
if err := call.Scan(&args); err != nil {
t.Fatalf("Scan: %v", err)
}
if args.Query != "hello" || args.Limit != 5 {
t.Errorf("Scan decoded %+v, want {hello 5}", args)
}
}
-381
View File
@@ -1,381 +0,0 @@
# AI Package
The `ai` package provides simple, high-level interfaces for AI model providers. It supports text generation (`Model`), image generation (`ImageModel`), and video generation (`VideoModel`).
## Interfaces
### Text Generation (Model)
The Model interface follows the same patterns as other go-micro packages (Registry, Client, Broker):
```go
type Model interface {
Init(...Option) error
Options() Options
Generate(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error)
Stream(ctx context.Context, req *Request, opts ...GenerateOption) (Stream, error)
String() string
}
```
## Quick Start
```go
import (
"context"
"go-micro.dev/v5/ai"
_ "go-micro.dev/v5/ai/anthropic"
_ "go-micro.dev/v5/ai/openai"
)
// Create a model
m := ai.New("openai",
ai.WithAPIKey("your-api-key"),
ai.WithModel("gpt-4o"),
)
// Generate a response
req := &ai.Request{
Prompt: "What is Go?",
SystemPrompt: "You are a helpful programming assistant",
}
resp, err := m.Generate(context.Background(), req)
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.Reply)
```
### Image Generation (ImageModel)
```go
type ImageModel interface {
GenerateImage(ctx context.Context, req *ImageRequest, opts ...GenerateOption) (*ImageResponse, error)
String() string
}
```
```go
import (
"go-micro.dev/v5/ai"
_ "go-micro.dev/v5/ai/atlascloud"
)
ig := ai.NewImage("atlascloud",
ai.WithAPIKey("your-api-key"),
)
resp, err := ig.GenerateImage(context.Background(), &ai.ImageRequest{
Prompt: "A Go gopher in space",
Size: "1024x1024",
})
fmt.Println(resp.Images[0].URL)
```
Providers that support image generation: **Atlas Cloud**, **OpenAI**.
### Video Generation (VideoModel)
```go
type VideoModel interface {
GenerateVideo(ctx context.Context, req *VideoRequest, opts ...GenerateOption) (*VideoResponse, error)
String() string
}
```
```go
import (
"go-micro.dev/v5/ai"
_ "go-micro.dev/v5/ai/atlascloud"
)
vg := ai.NewVideo("atlascloud",
ai.WithAPIKey("your-api-key"),
)
resp, err := vg.GenerateVideo(context.Background(), &ai.VideoRequest{
Prompt: "Microservices nodes animating with data flowing between them",
Images: []string{"https://example.com/diagram.png"}, // optional: image-to-video
Duration: 6,
})
fmt.Println(resp.URL)
```
Providers that support video generation: **Atlas Cloud**.
## Options
Configure the model using functional options:
```go
m := ai.New("anthropic",
ai.WithAPIKey("your-key"), // Required
ai.WithModel("claude-sonnet-4-20250514"), // Optional, uses provider default
ai.WithBaseURL("https://api.anthropic.com"), // Optional, uses provider default
)
```
You can also update options after creation:
```go
m.Init(
ai.WithModel("gpt-4o-mini"),
ai.WithAPIKey("new-key"),
)
```
## Using Tools
The model can automatically execute tool calls when provided with a tool handler:
```go
// Define a tool handler. It mirrors a go-micro RPC handler: context
// first, the call in, a result out.
toolHandler := func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
// Execute the tool and return results
switch call.Name {
case "get_weather":
return ai.ToolResult{ID: call.ID, Value: map[string]string{"temp": "72F"}, Content: `{"temp": "72F"}`}
default:
return ai.ToolResult{ID: call.ID, Content: `{"error": "unknown tool"}`}
}
}
// Create model with tool handler
m := ai.New("openai",
ai.WithAPIKey("your-key"),
ai.WithToolHandler(toolHandler),
)
// Provide tools in the request
req := &ai.Request{
Prompt: "What's the weather?",
SystemPrompt: "You are a helpful assistant",
Tools: []ai.Tool{
{
Name: "get_weather",
Description: "Get current weather",
Properties: map[string]any{
"location": map[string]any{
"type": "string",
"description": "City name",
},
},
},
},
}
// Generate will automatically call tools and return final answer
resp, err := m.Generate(context.Background(), req)
fmt.Println(resp.Answer) // Final answer after tool execution
```
## Response Structure
```go
type Response struct {
Reply string // Initial reply from model
ToolCalls []ToolCall // Tools the model wants to call
Answer string // Final answer (after tool execution if handler provided)
}
```
- `Reply`: The model's first response
- `ToolCalls`: List of tools the model requested (if any)
- `Answer`: The final answer after tools are executed (only set if ToolHandler is provided)
## Provider capability matrix
The CLI can print the provider capabilities registered in the current build:
```bash
micro ai providers
```
For automation and docs generation, emit the same matrix as stable JSON:
```bash
micro ai providers --json
```
It reports support from Go Micro's provider registry, so the matrix reflects the model, image, and video interfaces available to this binary rather than external provider marketing claims.
## Supported Providers
### Anthropic Claude
```go
m := ai.New("anthropic",
ai.WithAPIKey("sk-ant-..."),
ai.WithModel("claude-sonnet-4-20250514"), // default
)
```
Default model: `claude-sonnet-4-20250514`
Default base URL: `https://api.anthropic.com`
### OpenAI GPT
```go
m := ai.New("openai",
ai.WithAPIKey("sk-..."),
ai.WithModel("gpt-4o"), // default
)
```
Default model: `gpt-4o`
Default base URL: `https://api.openai.com`
### Google Gemini
```go
m := ai.New("gemini",
ai.WithAPIKey("your-key"),
ai.WithModel("gemini-2.5-flash"), // default
)
```
Default model: `gemini-2.5-flash`
Default base URL: `https://generativelanguage.googleapis.com`
Google Gemini uses its own API format with `system_instruction`, `contents` (not `messages`), and `functionDeclarations` for tool calling. The provider handles the translation automatically.
### Groq
```go
m := ai.New("groq",
ai.WithAPIKey("your-key"),
ai.WithModel("llama-3.3-70b-versatile"), // default
)
```
Default model: `llama-3.3-70b-versatile`
Default base URL: `https://api.groq.com/openai`
Groq provides ultra-fast inference for open-weight models via an OpenAI-compatible endpoint.
### Mistral
```go
m := ai.New("mistral",
ai.WithAPIKey("your-key"),
ai.WithModel("mistral-large-latest"), // default
)
```
Default model: `mistral-large-latest`
Default base URL: `https://api.mistral.ai`
Mistral AI is a European AI company offering high-performance models via an OpenAI-compatible endpoint.
### Together AI
```go
m := ai.New("together",
ai.WithAPIKey("your-key"),
ai.WithModel("meta-llama/Llama-3.3-70B-Instruct-Turbo"), // default
)
```
Default model: `meta-llama/Llama-3.3-70B-Instruct-Turbo`
Default base URL: `https://api.together.xyz`
Together AI provides fast inference for open-weight models via an OpenAI-compatible endpoint.
### Atlas Cloud
```go
m := ai.New("atlascloud",
ai.WithAPIKey("your-key"),
ai.WithModel("llama-3.3-70b"), // default
)
```
Default model: `llama-3.3-70b`
Default base URL: `https://api.atlascloud.ai`
Atlas Cloud is an enterprise AI infrastructure platform offering high-performance LLM APIs. It exposes an OpenAI-compatible chat completions endpoint with tool calling support.
### MiniMax
```go
m := ai.New("minimax",
ai.WithAPIKey("your-key"),
ai.WithModel("MiniMax-M3"), // default
)
```
Default model: `MiniMax-M3`
Default base URL: `https://api.minimax.io`
MiniMax offers its flagship MiniMax-M3 model via an OpenAI-compatible chat completions endpoint.
## Auto-Detection
Use `AutoDetectProvider()` to detect the provider from a base URL:
```go
provider := ai.AutoDetectProvider("https://api.anthropic.com")
// Returns "anthropic"
m := ai.New(provider, ai.WithAPIKey("..."))
```
## Adding a New Provider
See the full **[AI Provider Integration Guide](../internal/website/docs/guides/ai-provider-guide.md)** for a step-by-step walkthrough, checklist, and design notes.
Quick summary:
1. Create `ai/yourprovider/yourprovider.go` implementing `ai.Model`.
2. Call `ai.Register("yourprovider", ...)` in `init()`.
3. Add tests in `ai/yourprovider/yourprovider_test.go`.
4. Users enable the provider with a blank import:
```go
import _ "go-micro.dev/v5/ai/yourprovider"
```
We welcome contributions and sponsorships from AI infrastructure companies — see the guide for details.
## Comparison with Other Packages
The ai package follows the same patterns as other go-micro packages:
**Registry:**
```go
r := registry.NewRegistry(registry.Addrs("..."))
r.Register(service)
```
**Client:**
```go
c := client.NewClient(client.Retries(3))
c.Call(ctx, req, rsp)
```
**AI:**
```go
m := ai.New("openai", ai.WithAPIKey("..."))
m.Generate(ctx, req)
```
All use:
- `Init()` to update options
- `Options()` to get current options
- `String()` to get the implementation name
- Functional options pattern
## Testing
```bash
go test ./ai/...
```
## Examples
See the [server implementation](../cmd/micro/server/server.go) for a complete example of using the ai package with tool execution.
-290
View File
@@ -1,290 +0,0 @@
// Package anthropic implements the Anthropic Claude model provider
package anthropic
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"go-micro.dev/v6/ai"
)
func init() {
ai.Register("anthropic", func(opts ...ai.Option) ai.Model {
return NewProvider(opts...)
})
}
// Provider implements the ai.Model interface for Anthropic Claude
type Provider struct {
opts ai.Options
}
// NewProvider creates a new Anthropic provider
func NewProvider(opts ...ai.Option) *Provider {
options := ai.NewOptions(opts...)
// Set defaults if not provided
if options.Model == "" {
options.Model = "claude-sonnet-4-20250514"
}
if options.BaseURL == "" {
options.BaseURL = "https://api.anthropic.com"
}
return &Provider{
opts: options,
}
}
// Init initializes the provider with options
func (p *Provider) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&p.opts)
}
return nil
}
// Options returns the provider options
func (p *Provider) Options() ai.Options {
return p.opts
}
// String returns the provider name
func (p *Provider) String() string {
return "anthropic"
}
// Generate generates a response from the model
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
// Build tools for Anthropic format
var anthropicTools []map[string]any
for _, t := range req.Tools {
anthropicTools = append(anthropicTools, map[string]any{
"name": t.Name,
"description": t.Description,
"input_schema": map[string]any{
"type": "object",
"properties": t.Properties,
},
})
}
// Build initial request
apiReq := map[string]any{
"model": p.opts.Model,
"max_tokens": anthropicMaxTokens(p.opts),
"system": req.SystemPrompt,
"messages": threadAnthropicMessages(req),
}
if len(anthropicTools) > 0 {
apiReq["tools"] = anthropicTools
}
// Make API call
resp, rawContent, err := p.callAPI(ctx, apiReq)
if err != nil {
return nil, err
}
// If no tool calls or no handler, return as-is
if len(resp.ToolCalls) == 0 || p.opts.ToolHandler == nil {
return resp, nil
}
// Tool execution loop: execute tools, send results back, repeat
// until the model responds with text only (no more tool calls)
messages := append(threadAnthropicMessages(req),
map[string]any{"role": "assistant", "content": cleanContent(rawContent)},
)
pendingCalls := resp.ToolCalls
for rounds := 0; rounds < 10; rounds++ {
var toolResultBlocks []map[string]any
for i := range pendingCalls {
content := p.opts.ToolHandler(ctx, pendingCalls[i]).Content
pendingCalls[i].Result = content
toolResultBlocks = append(toolResultBlocks, map[string]any{
"type": "tool_result",
"tool_use_id": pendingCalls[i].ID,
"content": content,
})
}
messages = append(messages, map[string]any{
"role": "user",
"content": toolResultBlocks,
})
followUpReq := map[string]any{
"model": p.opts.Model,
"max_tokens": anthropicMaxTokens(p.opts),
"system": req.SystemPrompt,
"messages": messages,
}
if len(anthropicTools) > 0 {
followUpReq["tools"] = anthropicTools
}
followUpResp, followUpRaw, err := p.callAPI(ctx, followUpReq)
if err != nil {
break
}
if len(followUpResp.ToolCalls) > 0 {
resp.ToolCalls = append(resp.ToolCalls, followUpResp.ToolCalls...)
pendingCalls = followUpResp.ToolCalls
messages = append(messages, map[string]any{
"role": "assistant",
"content": cleanContent(followUpRaw),
})
continue
}
if followUpResp.Reply != "" {
resp.Answer = followUpResp.Reply
}
break
}
return resp, nil
}
// Stream generates a streaming response (not yet implemented)
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("%w: anthropic provider", ai.ErrStreamingUnsupported)
}
// callAPI makes an HTTP request to the Anthropic API
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, any, error) {
// Marshal request
reqBody, err := json.Marshal(req)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
}
// Build HTTP request
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/messages"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, nil, fmt.Errorf("failed to create request: %w", err)
}
// Set headers
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("x-api-key", p.opts.APIKey)
httpReq.Header.Set("anthropic-version", "2023-06-01")
// Make request
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, nil, fmt.Errorf("API request failed: %w", err)
}
defer httpResp.Body.Close()
// Read response
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
// Parse response
var anthropicResp struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
ID string `json:"id"`
Name string `json:"name"`
Input json.RawMessage `json:"input"`
} `json:"content"`
StopReason string `json:"stop_reason"`
}
if err := json.Unmarshal(respBody, &anthropicResp); err != nil {
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
}
response := &ai.Response{}
// Extract text reply
var replyParts []string
for _, block := range anthropicResp.Content {
if block.Type == "text" && block.Text != "" {
replyParts = append(replyParts, block.Text)
}
}
if len(replyParts) > 0 {
response.Reply = strings.Join(replyParts, "\n")
}
// Extract tool calls
for _, block := range anthropicResp.Content {
if block.Type == "tool_use" {
var input map[string]any
if err := json.Unmarshal(block.Input, &input); err != nil {
input = map[string]any{}
}
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
ID: block.ID,
Name: block.Name,
Input: input,
})
}
}
return response, anthropicResp.Content, nil
}
// cleanContent strips fields from response content blocks that Anthropic
// rejects when sent back as assistant message content (e.g. "id" on text blocks).
func cleanContent(raw any) any {
blocks, ok := raw.([]struct {
Type string `json:"type"`
Text string `json:"text"`
ID string `json:"id"`
Name string `json:"name"`
Input json.RawMessage `json:"input"`
})
if !ok {
return raw
}
var cleaned []map[string]any
for _, b := range blocks {
switch b.Type {
case "text":
cleaned = append(cleaned, map[string]any{"type": "text", "text": b.Text})
case "tool_use":
var input any
_ = json.Unmarshal(b.Input, &input)
cleaned = append(cleaned, map[string]any{"type": "tool_use", "id": b.ID, "name": b.Name, "input": input})
}
}
return cleaned
}
// threadAnthropicMessages builds the Anthropic messages array from the
// conversation history (req.Messages) followed by the current prompt. The
// system prompt is sent separately via the top-level "system" field.
func threadAnthropicMessages(req *ai.Request) []map[string]any {
msgs := make([]map[string]any, 0, len(req.Messages)+1)
for _, m := range req.Messages {
msgs = append(msgs, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
msgs = append(msgs, map[string]any{"role": "user", "content": req.Prompt})
}
return msgs
}
func anthropicMaxTokens(o ai.Options) int {
if o.MaxTokens > 0 {
return o.MaxTokens
}
return 8192
}
-95
View File
@@ -1,95 +0,0 @@
package anthropic
import (
"context"
"errors"
"testing"
"go-micro.dev/v6/ai"
)
func TestProvider_String(t *testing.T) {
p := NewProvider()
if p.String() != "anthropic" {
t.Errorf("Expected provider name 'anthropic', got '%s'", p.String())
}
}
func TestProvider_Init(t *testing.T) {
p := NewProvider()
err := p.Init(
ai.WithModel("test-model"),
ai.WithAPIKey("test-key"),
ai.WithBaseURL("https://test.com"),
)
if err != nil {
t.Fatalf("Init failed: %v", err)
}
opts := p.Options()
if opts.Model != "test-model" {
t.Errorf("Expected model 'test-model', got '%s'", opts.Model)
}
if opts.APIKey != "test-key" {
t.Errorf("Expected API key 'test-key', got '%s'", opts.APIKey)
}
if opts.BaseURL != "https://test.com" {
t.Errorf("Expected base URL 'https://test.com', got '%s'", opts.BaseURL)
}
}
func TestProvider_Options(t *testing.T) {
p := NewProvider(
ai.WithModel("custom-model"),
ai.WithAPIKey("my-key"),
)
opts := p.Options()
if opts.Model != "custom-model" {
t.Errorf("Expected model 'custom-model', got '%s'", opts.Model)
}
if opts.APIKey != "my-key" {
t.Errorf("Expected API key 'my-key', got '%s'", opts.APIKey)
}
}
func TestProvider_Defaults(t *testing.T) {
p := NewProvider()
opts := p.Options()
if opts.Model != "claude-sonnet-4-20250514" {
t.Errorf("Expected default model 'claude-sonnet-4-20250514', got '%s'", opts.Model)
}
if opts.BaseURL != "https://api.anthropic.com" {
t.Errorf("Expected default base URL 'https://api.anthropic.com', got '%s'", opts.BaseURL)
}
}
func TestProvider_Generate_NoAPIKey(t *testing.T) {
p := NewProvider()
req := &ai.Request{
Prompt: "Hello",
SystemPrompt: "You are helpful",
}
_, err := p.Generate(context.Background(), req)
if err == nil {
t.Error("Expected error when API key is missing, got nil")
}
}
func TestProvider_Stream_NotImplemented(t *testing.T) {
p := NewProvider()
req := &ai.Request{
Prompt: "Hello",
}
_, err := p.Stream(context.Background(), req)
if !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
}
}
-682
View File
@@ -1,682 +0,0 @@
// Package atlascloud implements the Atlas Cloud model provider.
//
// Atlas Cloud is an enterprise AI infrastructure platform offering
// high-performance LLM, image, and video APIs. It exposes
// OpenAI-compatible endpoints for chat completions and image
// generation.
//
// Usage:
//
// import _ "go-micro.dev/v6/ai/atlascloud"
//
// m := ai.New("atlascloud",
// ai.WithAPIKey("your-api-key"),
// )
//
// // Image generation
// ig := ai.NewImage("atlascloud",
// ai.WithAPIKey("your-api-key"),
// )
package atlascloud
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"go-micro.dev/v6/ai"
)
func init() {
ai.Register("atlascloud", func(opts ...ai.Option) ai.Model {
return NewProvider(opts...)
})
ai.RegisterImage("atlascloud", func(opts ...ai.Option) ai.ImageModel {
return NewProvider(opts...)
})
ai.RegisterVideo("atlascloud", func(opts ...ai.Option) ai.VideoModel {
return NewProvider(opts...)
})
ai.RegisterStream("atlascloud")
}
// Provider implements the ai.Model interface for Atlas Cloud.
type Provider struct {
opts ai.Options
}
type atlasToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
// NewProvider creates a new Atlas Cloud provider.
func NewProvider(opts ...ai.Option) *Provider {
options := ai.NewOptions(opts...)
if options.Model == "" {
// Allow the chat model to be selected via the ATLASCLOUD_MODEL env var
// (e.g. to run CI conformance against a stronger tool-use model) without
// a code change; fall back to a sensible default otherwise.
if m := os.Getenv("ATLASCLOUD_MODEL"); m != "" {
options.Model = m
} else {
options.Model = "deepseek-ai/DeepSeek-V3-0324"
}
}
if options.BaseURL == "" {
options.BaseURL = "https://api.atlascloud.ai"
}
return &Provider{opts: options}
}
func (p *Provider) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&p.opts)
}
return nil
}
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) {
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},
}
for _, m := range req.Messages {
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
}
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
}
if p.opts.MaxTokens > 0 {
apiReq["max_tokens"] = p.opts.MaxTokens
}
if len(tools) > 0 {
apiReq["tools"] = tools
}
resp, rawMessage, err := p.callAPI(ctx, "chat", apiReq)
if err != nil {
return nil, err
}
if len(resp.ToolCalls) == 0 {
return resp, nil
}
if p.opts.ToolHandler != nil {
var toolResults []string
followUpMessages := append(messages, map[string]any{
"role": "assistant",
"content": rawMessage["content"],
"tool_calls": rawMessage["tool_calls"],
})
for _, tc := range resp.ToolCalls {
content := p.opts.ToolHandler(ctx, tc).Content
if content != "" {
toolResults = append(toolResults, content)
}
followUpMessages = append(followUpMessages, map[string]any{
"role": "tool",
"tool_call_id": tc.ID,
"content": content,
})
}
followUpReq := map[string]any{
"model": p.opts.Model,
"messages": followUpMessages,
}
followUpResp, _, err := p.callAPI(ctx, "tool-follow-up", followUpReq)
if err != nil {
return nil, err
}
if followUpResp.Reply != "" {
resp.Answer = followUpResp.Reply
} else if len(toolResults) > 0 {
resp.Answer = strings.Join(toolResults, "\n")
}
}
return resp, nil
}
// Stream generates a streaming response from Atlas Cloud's OpenAI-compatible
// chat completions endpoint, emitting content deltas as they arrive.
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
if len(req.Tools) > 0 {
return nil, fmt.Errorf("%w: atlascloud streaming does not expose tools", ai.ErrStreamingUnsupported)
}
messages := []map[string]any{
{"role": "system", "content": req.SystemPrompt},
}
for _, m := range req.Messages {
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
}
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
"stream": true,
"stream_options": map[string]any{"include_usage": true},
}
if p.opts.MaxTokens > 0 {
apiReq["max_tokens"] = p.opts.MaxTokens
}
reqBody, err := json.Marshal(apiReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
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("Authorization", "Bearer "+p.opts.APIKey)
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("stream API request failed: %w", err)
}
if httpResp.StatusCode != http.StatusOK {
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
return nil, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(respBody))
}
return &atlasStream{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
}
type atlasStream struct {
body io.ReadCloser
scanner *bufio.Scanner
closed bool
}
func (s *atlasStream) Recv() (*ai.Response, error) {
for s.scanner.Scan() {
line := strings.TrimSpace(s.scanner.Text())
if line == "" || strings.HasPrefix(line, ":") {
continue
}
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "[DONE]" {
return nil, io.EOF
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
}
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
return &ai.Response{Reply: chunk.Choices[0].Delta.Content}, nil
}
// Final chunk (after include_usage) carries token usage and no content.
if chunk.Usage != nil {
return &ai.Response{Usage: ai.Usage{
InputTokens: chunk.Usage.PromptTokens,
OutputTokens: chunk.Usage.CompletionTokens,
TotalTokens: chunk.Usage.TotalTokens,
}}, nil
}
continue
}
if err := s.scanner.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}
func (s *atlasStream) Close() error {
if s.closed {
return nil
}
s.closed = true
return s.body.Close()
}
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 {
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, nil, fmt.Errorf("API request failed: %w", err)
}
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s) during atlascloud %s request (%s): %s", httpResp.Status, phase, atlascloudRequestSummary(req), string(respBody))
}
var chatResp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ToolCalls []atlasToolCall `json:"tool_calls"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
}
if len(chatResp.Choices) == 0 {
return nil, nil, fmt.Errorf("no response from API")
}
choice := chatResp.Choices[0]
response := &ai.Response{
Reply: choice.Message.Content,
}
for _, tc := range choice.Message.ToolCalls {
var input map[string]any
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
input = map[string]any{}
}
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Input: input,
})
}
rawMessage := map[string]any{
"content": choice.Message.Content,
"tool_calls": normalizeAtlasCloudToolCalls(choice.Message.ToolCalls),
}
return response, rawMessage, nil
}
func normalizeAtlasCloudToolCalls(toolCalls []atlasToolCall) []map[string]any {
out := make([]map[string]any, 0, len(toolCalls))
for _, tc := range toolCalls {
toolType := tc.Type
if toolType == "" {
toolType = "function"
}
out = append(out, map[string]any{
"id": tc.ID,
"type": toolType,
"function": map[string]any{
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
},
})
}
return out
}
func atlascloudRequestSummary(req map[string]any) string {
parts := []string{}
if model, ok := req["model"].(string); ok && model != "" {
parts = append(parts, "model="+model)
}
if messages, ok := req["messages"].([]map[string]any); ok {
parts = append(parts, fmt.Sprintf("messages=%d", len(messages)))
if len(messages) > 0 {
last := messages[len(messages)-1]
if role, ok := last["role"].(string); ok && role != "" {
parts = append(parts, "last_role="+role)
}
if _, ok := last["tool_call_id"].(string); ok {
parts = append(parts, "last_has_tool_call_id=true")
}
}
}
if tools, ok := req["tools"].([]map[string]any); ok {
names := make([]string, 0, len(tools))
for _, tool := range tools {
fn, _ := tool["function"].(map[string]any)
name, _ := fn["name"].(string)
if name != "" {
names = append(names, name)
}
}
parts = append(parts, fmt.Sprintf("tools=%d", len(tools)))
if len(names) > 0 {
parts = append(parts, "tool_names="+strings.Join(names, ","))
}
}
if len(parts) == 0 {
return "request_context=unavailable"
}
return strings.Join(parts, " ")
}
const defaultImageModel = "openai/gpt-image-2/text-to-image"
// GenerateImage creates an image using Atlas Cloud's async image API.
// It submits the job and polls until completion or context cancellation.
func (p *Provider) GenerateImage(ctx context.Context, req *ai.ImageRequest, opts ...ai.GenerateOption) (*ai.ImageResponse, error) {
model := req.Model
if model == "" {
model = defaultImageModel
}
quality := req.Quality
if quality == "" {
quality = "medium"
}
outputFmt := req.OutputFormat
if outputFmt == "" {
outputFmt = "png"
}
size := req.Size
if size == "" {
size = "1024x1024"
}
apiReq := map[string]any{
"model": model,
"prompt": req.Prompt,
"quality": quality,
"output_format": outputFmt,
"size": size,
"enable_sync_mode": false,
"enable_base64_output": false,
"moderation": "low",
}
reqBody, err := json.Marshal(apiReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/api/v1/model/generateImage"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("API request failed: %w", err)
}
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var submitResp struct {
Code int `json:"code"`
Msg string `json:"message"`
Data struct {
ID string `json:"id"`
Status string `json:"status"`
} `json:"data"`
}
if err := json.Unmarshal(respBody, &submitResp); err != nil {
return nil, fmt.Errorf("failed to parse submit response: %w", err)
}
if submitResp.Code != 200 {
return nil, fmt.Errorf("API error: %s", submitResp.Msg)
}
predictionID := submitResp.Data.ID
pollURL := strings.TrimRight(p.opts.BaseURL, "/") + "/api/v1/model/prediction/" + predictionID
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-ticker.C:
result, err := p.pollPrediction(ctx, pollURL)
if err != nil {
return nil, err
}
if result != nil {
return result, nil
}
}
}
}
func (p *Provider) pollPrediction(ctx context.Context, url string) (*ai.ImageResponse, error) {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("poll request failed: %w", err)
}
defer httpResp.Body.Close()
body, _ := io.ReadAll(httpResp.Body)
var pollResp struct {
Data struct {
Status string `json:"status"`
Outputs []string `json:"outputs"`
Error string `json:"error"`
} `json:"data"`
}
if err := json.Unmarshal(body, &pollResp); err != nil {
return nil, fmt.Errorf("failed to parse poll response: %w", err)
}
switch pollResp.Data.Status {
case "completed":
resp := &ai.ImageResponse{}
for _, output := range pollResp.Data.Outputs {
resp.Images = append(resp.Images, ai.Image{URL: output})
}
return resp, nil
case "failed":
return nil, fmt.Errorf("image generation failed: %s", pollResp.Data.Error)
default:
return nil, nil
}
}
const defaultVideoModel = "google/gemini-omni-flash/image-to-video-developer"
// GenerateVideo creates a video using Atlas Cloud's async video API.
// Supports text-to-video and image-to-video depending on whether
// Images are provided in the request.
func (p *Provider) GenerateVideo(ctx context.Context, req *ai.VideoRequest, opts ...ai.GenerateOption) (*ai.VideoResponse, error) {
model := req.Model
if model == "" {
model = defaultVideoModel
}
duration := req.Duration
if duration <= 0 {
duration = 6
}
aspect := req.AspectRatio
if aspect == "" {
aspect = "16:9"
}
resolution := req.Resolution
if resolution == "" {
resolution = "720p"
}
apiReq := map[string]any{
"model": model,
"prompt": req.Prompt,
"duration": duration,
"aspect_ratio": aspect,
"resolution": resolution,
"seed": -1,
}
if len(req.Images) > 0 {
apiReq["images"] = req.Images
}
reqBody, err := json.Marshal(apiReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/api/v1/model/generateVideo"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("API request failed: %w", err)
}
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var submitResp struct {
Code int `json:"code"`
Msg string `json:"message"`
Data struct {
ID string `json:"id"`
Status string `json:"status"`
} `json:"data"`
}
if err := json.Unmarshal(respBody, &submitResp); err != nil {
return nil, fmt.Errorf("failed to parse submit response: %w", err)
}
if submitResp.Code != 200 {
return nil, fmt.Errorf("API error: %s", submitResp.Msg)
}
pollURL := strings.TrimRight(p.opts.BaseURL, "/") + "/api/v1/model/prediction/" + submitResp.Data.ID
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-ticker.C:
result, err := p.pollVideo(ctx, pollURL)
if err != nil {
return nil, err
}
if result != nil {
return result, nil
}
}
}
}
func (p *Provider) pollVideo(ctx context.Context, url string) (*ai.VideoResponse, error) {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("poll request failed: %w", err)
}
defer httpResp.Body.Close()
body, _ := io.ReadAll(httpResp.Body)
var pollResp struct {
Data struct {
Status string `json:"status"`
Outputs []string `json:"outputs"`
Error string `json:"error"`
} `json:"data"`
}
if err := json.Unmarshal(body, &pollResp); err != nil {
return nil, fmt.Errorf("failed to parse poll response: %w", err)
}
switch pollResp.Data.Status {
case "completed", "succeeded":
if len(pollResp.Data.Outputs) == 0 {
return nil, fmt.Errorf("video completed but no outputs returned")
}
return &ai.VideoResponse{URL: pollResp.Data.Outputs[0]}, nil
case "failed":
return nil, fmt.Errorf("video generation failed: %s", pollResp.Data.Error)
default:
return nil, nil
}
}
-377
View File
@@ -1,377 +0,0 @@
package atlascloud
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go-micro.dev/v6/ai"
)
func TestProvider_String(t *testing.T) {
p := NewProvider()
if p.String() != "atlascloud" {
t.Errorf("Expected provider name 'atlascloud', got '%s'", p.String())
}
}
func TestProvider_Init(t *testing.T) {
p := NewProvider()
err := p.Init(
ai.WithModel("test-model"),
ai.WithAPIKey("test-key"),
ai.WithBaseURL("https://test.com"),
)
if err != nil {
t.Fatalf("Init failed: %v", err)
}
opts := p.Options()
if opts.Model != "test-model" {
t.Errorf("Expected model 'test-model', got '%s'", opts.Model)
}
if opts.APIKey != "test-key" {
t.Errorf("Expected API key 'test-key', got '%s'", opts.APIKey)
}
if opts.BaseURL != "https://test.com" {
t.Errorf("Expected base URL 'https://test.com', got '%s'", opts.BaseURL)
}
}
func TestProvider_Options(t *testing.T) {
p := NewProvider(
ai.WithModel("custom-model"),
ai.WithAPIKey("my-key"),
)
opts := p.Options()
if opts.Model != "custom-model" {
t.Errorf("Expected model 'custom-model', got '%s'", opts.Model)
}
if opts.APIKey != "my-key" {
t.Errorf("Expected API key 'my-key', got '%s'", opts.APIKey)
}
}
func TestProvider_Defaults(t *testing.T) {
p := NewProvider()
opts := p.Options()
if opts.Model != "deepseek-ai/DeepSeek-V3-0324" {
t.Errorf("Expected default model 'deepseek-ai/DeepSeek-V3-0324', got '%s'", opts.Model)
}
if opts.BaseURL != "https://api.atlascloud.ai" {
t.Errorf("Expected default base URL 'https://api.atlascloud.ai', got '%s'", opts.BaseURL)
}
}
func TestProvider_Generate_NoAPIKey(t *testing.T) {
p := NewProvider()
req := &ai.Request{
Prompt: "Hello",
SystemPrompt: "You are helpful",
}
_, err := p.Generate(context.Background(), req)
if err == nil {
t.Error("Expected error when API key is missing, got nil")
}
}
func TestProvider_Stream(t *testing.T) {
var sawStream, sawIncludeUsage bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Errorf("path = %s, want /v1/chat/completions", r.URL.Path)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
sawStream, _ = body["stream"].(bool)
if so, ok := body["stream_options"].(map[string]any); ok {
sawIncludeUsage, _ = so["include_usage"].(bool)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[],\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":2,\"total_tokens\":9}}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer ts.Close()
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
if !sawStream {
t.Fatal("stream request did not set stream=true")
}
if !sawIncludeUsage {
t.Fatal("stream request did not set stream_options.include_usage=true")
}
first, err := stream.Recv()
if err != nil || first.Reply != "hel" {
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
}
second, err := stream.Recv()
if err != nil || second.Reply != "lo" {
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
}
usage, err := stream.Recv()
if err != nil {
t.Fatalf("usage chunk error: %v", err)
}
if usage.Usage.TotalTokens != 9 || usage.Usage.InputTokens != 7 || usage.Usage.OutputTokens != 2 {
t.Fatalf("usage = %#v; want input=7 output=2 total=9", usage.Usage)
}
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
t.Fatalf("final error = %v, want EOF", err)
}
}
func TestProvider_StreamWithToolsFallsBack(t *testing.T) {
p := NewProvider(ai.WithAPIKey("test-key"))
_, err := p.Stream(context.Background(), &ai.Request{
Prompt: "call a tool",
Tools: []ai.Tool{{
Name: "fallback_echo",
Description: "echo fallback marker",
Properties: map[string]any{"value": map[string]any{"type": "string"}},
}},
})
if !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream with tools error = %v, want ErrStreamingUnsupported", err)
}
}
func TestProvider_GenerateToolCallEmptyFollowUpUsesToolResult(t *testing.T) {
var calls int
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Errorf("path = %s, want /v1/chat/completions", r.URL.Path)
}
calls++
w.Header().Set("Content-Type", "application/json")
switch calls {
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":""}}]}`))
default:
t.Fatalf("unexpected API call %d", calls)
}
}))
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("tool name = %q, want conformance_echo", call.Name)
}
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 conformance marker",
Properties: map[string]any{"value": map[string]any{"type": "string"}},
}},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if calls != 2 {
t.Fatalf("API calls = %d, want 2", calls)
}
if resp.Answer != `{"marker":"agent-conformance-ok"}` {
t.Fatalf("Answer = %q, want tool result fallback", resp.Answer)
}
}
func TestProvider_GenerateMinimaxToolRequests(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":"done"}}]}`))
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"),
ai.WithToolHandler(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
return ai.ToolResult{ID: call.ID, Content: `{"marker":"agent-conformance-ok"}`}
}),
)
resp, err := p.Generate(context.Background(), &ai.Request{
SystemPrompt: "You are helpful.",
Prompt: "call a tool",
Tools: []ai.Tool{{
Name: "conformance_echo",
Description: "echo conformance marker",
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 len(bodies) != 2 {
t.Fatalf("captured requests = %d, want 2", len(bodies))
}
if got := bodies[0]["model"]; got != "minimaxai/minimax-m3" {
t.Fatalf("initial model = %v", got)
}
tools, ok := bodies[0]["tools"].([]any)
if !ok || len(tools) != 1 {
t.Fatalf("initial tools = %#v, want one tool", bodies[0]["tools"])
}
tool := tools[0].(map[string]any)
if tool["type"] != "function" {
t.Fatalf("tool type = %v, want function", tool["type"])
}
fn := tool["function"].(map[string]any)
if fn["name"] != "conformance_echo" {
t.Fatalf("tool function name = %v", fn["name"])
}
params := fn["parameters"].(map[string]any)
if params["type"] != "object" {
t.Fatalf("parameters type = %v, want object", params["type"])
}
followUpMessages := bodies[1]["messages"].([]any)
if len(followUpMessages) != 4 {
t.Fatalf("follow-up messages = %d, want 4", len(followUpMessages))
}
assistant := followUpMessages[2].(map[string]any)
if assistant["role"] != "assistant" {
t.Fatalf("assistant role = %v", assistant["role"])
}
assistantCalls := assistant["tool_calls"].([]any)
assistantCall := assistantCalls[0].(map[string]any)
if assistantCall["type"] != "function" {
t.Fatalf("assistant tool call type = %v, want function", assistantCall["type"])
}
toolResult := followUpMessages[3].(map[string]any)
if toolResult["role"] != "tool" || toolResult["tool_call_id"] != "call-1" {
t.Fatalf("tool result message = %#v", toolResult)
}
}
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)
}))
defer ts.Close()
p := NewProvider(
ai.WithAPIKey("test-key"),
ai.WithBaseURL(ts.URL),
ai.WithModel("minimaxai/minimax-m3"),
)
_, err := p.Generate(context.Background(), &ai.Request{
Prompt: "call a tool",
Tools: []ai.Tool{{
Name: "conformance_echo",
Description: "echo conformance marker",
Properties: map[string]any{"value": map[string]any{"type": "string"}},
}},
})
if err == nil {
t.Fatal("Generate error = nil, want 400")
}
msg := err.Error()
for _, want := range []string{"400 Bad Request", "atlascloud chat request", "model=minimaxai/minimax-m3", "tools=1", "tool_names=conformance_echo"} {
if !strings.Contains(msg, want) {
t.Fatalf("error %q missing %q", msg, want)
}
}
if strings.Contains(msg, "test-key") {
t.Fatalf("error leaked API key: %s", msg)
}
}
func TestProvider_Registration(t *testing.T) {
m := ai.New("atlascloud", ai.WithAPIKey("test"))
if m == nil {
t.Fatal("ai.New('atlascloud') returned nil — provider not registered")
}
if m.String() != "atlascloud" {
t.Errorf("Expected 'atlascloud', got '%s'", m.String())
}
}
func TestProvider_ImageRegistration(t *testing.T) {
ig := ai.NewImage("atlascloud", ai.WithAPIKey("test"))
if ig == nil {
t.Fatal("ai.NewImage('atlascloud') returned nil — image provider not registered")
}
if ig.String() != "atlascloud" {
t.Errorf("Expected 'atlascloud', got '%s'", ig.String())
}
}
func TestProvider_GenerateImage_NoAPIKey(t *testing.T) {
p := NewProvider()
_, err := p.GenerateImage(context.Background(), &ai.ImageRequest{Prompt: "a cat"})
if err == nil {
t.Error("Expected error when API key is missing, got nil")
}
}
func TestProvider_ImplementsImageModel(t *testing.T) {
var _ ai.ImageModel = (*Provider)(nil)
}
func TestProvider_VideoRegistration(t *testing.T) {
vg := ai.NewVideo("atlascloud", ai.WithAPIKey("test"))
if vg == nil {
t.Fatal("ai.NewVideo('atlascloud') returned nil — video provider not registered")
}
if vg.String() != "atlascloud" {
t.Errorf("Expected 'atlascloud', got '%s'", vg.String())
}
}
func TestProvider_GenerateVideo_NoAPIKey(t *testing.T) {
p := NewProvider()
_, err := p.GenerateVideo(context.Background(), &ai.VideoRequest{Prompt: "a cat"})
if err == nil {
t.Error("Expected error when API key is missing, got nil")
}
}
func TestProvider_ImplementsVideoModel(t *testing.T) {
var _ ai.VideoModel = (*Provider)(nil)
}
-140
View File
@@ -1,140 +0,0 @@
package ai
import "sort"
// CapabilityRow is one deterministic row in a provider capability matrix.
type CapabilityRow struct {
// Provider is the registered provider name.
Provider string `json:"provider"`
Capabilities
}
// Capabilities describes the AI interfaces a provider has registered.
// It is intentionally based on package registration rather than external
// provider marketing claims, so it reflects what this build can actually use.
type Capabilities struct {
// Model reports whether ai.New can construct a chat/text model provider.
Model bool `json:"model"`
// Image reports whether ai.NewImage can construct an image model provider.
Image bool `json:"image"`
// Video reports whether ai.NewVideo can construct a video model provider.
Video bool `json:"video"`
// Stream reports whether the provider has registered end-to-end token streaming.
// Providers that only satisfy the Model interface with ErrStreamingUnsupported
// leave this false until their Stream implementation is usable.
Stream bool `json:"stream"`
}
// ProviderCapabilities reports the capabilities registered for provider.
func ProviderCapabilities(provider string) Capabilities {
_, hasModel := providers[provider]
_, hasImage := imageProviders[provider]
_, hasVideo := videoProviders[provider]
_, hasStream := streamProviders[provider]
return Capabilities{
Model: hasModel,
Image: hasImage,
Video: hasVideo,
Stream: hasStream,
}
}
// CapabilityMatrix returns a snapshot of all registered AI providers and the
// interfaces they support. The returned map is a copy and can be modified by
// callers without mutating the registry. Use CapabilityRows when rendering a
// deterministic table or report.
func CapabilityMatrix() map[string]Capabilities {
names := map[string]struct{}{}
for name := range providers {
names[name] = struct{}{}
}
for name := range imageProviders {
names[name] = struct{}{}
}
for name := range videoProviders {
names[name] = struct{}{}
}
for name := range streamProviders {
names[name] = struct{}{}
}
matrix := make(map[string]Capabilities, len(names))
for name := range names {
matrix[name] = ProviderCapabilities(name)
}
return matrix
}
// CapabilityRows returns a deterministic capability support matrix for every
// registered AI provider. It is the ordered form of CapabilityMatrix, intended
// for CLIs, docs generators, and conformance reports that need stable output.
func CapabilityRows() []CapabilityRow {
names := RegisteredProviders("")
rows := make([]CapabilityRow, 0, len(names))
for _, name := range names {
rows = append(rows, CapabilityRow{
Provider: name,
Capabilities: ProviderCapabilities(name),
})
}
return rows
}
// RegisterStream records that provider has a usable Stream implementation.
// Providers should call this from init alongside Register once Stream returns
// chunks instead of ErrStreamingUnsupported.
func RegisterStream(provider string) {
streamProviders[provider] = struct{}{}
}
var streamProviders = make(map[string]struct{})
// RegisteredProviders returns the registered provider names in sorted order.
// kind may be "model", "image", "video", "stream", or empty for the union of all
// provider registries.
func RegisteredProviders(kind string) []string {
names := map[string]struct{}{}
add := func(registry any) {
switch r := registry.(type) {
case map[string]NewFunc:
for name := range r {
names[name] = struct{}{}
}
case map[string]NewImageFunc:
for name := range r {
names[name] = struct{}{}
}
case map[string]NewVideoFunc:
for name := range r {
names[name] = struct{}{}
}
case map[string]struct{}:
for name := range r {
names[name] = struct{}{}
}
}
}
switch kind {
case "model":
add(providers)
case "stream":
add(streamProviders)
case "image":
add(imageProviders)
case "video":
add(videoProviders)
default:
add(providers)
add(imageProviders)
add(videoProviders)
}
out := make([]string, 0, len(names))
for name := range names {
out = append(out, name)
}
sort.Strings(out)
return out
}
-97
View File
@@ -1,97 +0,0 @@
package ai_test
import (
"reflect"
"testing"
"go-micro.dev/v6/ai"
_ "go-micro.dev/v6/ai/anthropic"
_ "go-micro.dev/v6/ai/atlascloud"
_ "go-micro.dev/v6/ai/gemini"
_ "go-micro.dev/v6/ai/groq"
_ "go-micro.dev/v6/ai/minimax"
_ "go-micro.dev/v6/ai/mistral"
_ "go-micro.dev/v6/ai/openai"
_ "go-micro.dev/v6/ai/together"
)
func TestRegisteredProviders(t *testing.T) {
got := ai.RegisteredProviders("")
want := []string{"anthropic", "atlascloud", "gemini", "groq", "minimax", "mistral", "openai", "together"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders() = %#v, want %#v", got, want)
}
got = ai.RegisteredProviders("image")
want = []string{"atlascloud", "openai"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders(image) = %#v, want %#v", got, want)
}
got = ai.RegisteredProviders("video")
want = []string{"atlascloud"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders(video) = %#v, want %#v", got, want)
}
got = ai.RegisteredProviders("stream")
want = []string{"atlascloud", "groq", "minimax", "mistral", "openai", "together"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
}
}
func TestCapabilityRows(t *testing.T) {
got := ai.CapabilityRows()
want := []ai.CapabilityRow{
{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}},
{Provider: "minimax", Capabilities: ai.Capabilities{Model: true, Stream: true}},
{Provider: "mistral", Capabilities: ai.Capabilities{Model: true, Stream: true}},
{Provider: "openai", Capabilities: ai.Capabilities{Model: true, Image: true, Stream: true}},
{Provider: "together", Capabilities: ai.Capabilities{Model: true, Stream: true}},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("CapabilityRows() = %#v, want %#v", got, want)
}
}
func TestCapabilityMatrix(t *testing.T) {
matrix := ai.CapabilityMatrix()
for _, provider := range []string{"anthropic", "atlascloud", "gemini", "groq", "minimax", "mistral", "openai", "together"} {
caps, ok := matrix[provider]
if !ok {
t.Fatalf("CapabilityMatrix missing %q", provider)
}
if !caps.Model {
t.Fatalf("CapabilityMatrix(%s).Model = false, want true", provider)
}
}
if caps := ai.ProviderCapabilities("openai"); caps != (ai.Capabilities{Model: true, Image: true, Stream: true}) {
t.Fatalf("ProviderCapabilities(openai) = %#v", caps)
}
if caps := ai.ProviderCapabilities("atlascloud"); caps != (ai.Capabilities{Model: true, Image: true, Video: true, Stream: true}) {
t.Fatalf("ProviderCapabilities(atlascloud) = %#v", caps)
}
if caps := ai.ProviderCapabilities("missing"); caps != (ai.Capabilities{}) {
t.Fatalf("ProviderCapabilities(missing) = %#v", caps)
}
}
func TestRegisterStream(t *testing.T) {
ai.RegisterStream("test-stream")
if caps := ai.ProviderCapabilities("test-stream"); caps != (ai.Capabilities{Stream: true}) {
t.Fatalf("ProviderCapabilities(test-stream) = %#v", caps)
}
got := ai.RegisteredProviders("stream")
want := []string{"atlascloud", "groq", "minimax", "mistral", "openai", "test-stream", "together"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
}
}
-22
View File
@@ -1,22 +0,0 @@
// Package flow is maintained for backward compatibility.
// The canonical import is go-micro.dev/v6/flow.
package flow
import "go-micro.dev/v6/flow"
// Re-export types for backward compatibility.
type Flow = flow.Flow
type Options = flow.Options
type Option = flow.Option
type Result = flow.Result
var New = flow.New
var Trigger = flow.Trigger
var Prompt = flow.Prompt
var SystemPrompt = flow.SystemPrompt
var Provider = flow.Provider
var APIKey = flow.APIKey
var Model = flow.Model
var BaseURL = flow.BaseURL
var HistoryLimit = flow.HistoryLimit
var OnResult = flow.OnResult
-226
View File
@@ -1,226 +0,0 @@
// Package gemini implements the Google Gemini model provider.
//
// Usage:
//
// import _ "go-micro.dev/v6/ai/gemini"
//
// m := ai.New("gemini",
// ai.WithAPIKey("your-api-key"),
// )
package gemini
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"go-micro.dev/v6/ai"
)
func init() {
ai.Register("gemini", func(opts ...ai.Option) ai.Model {
return NewProvider(opts...)
})
}
// Provider implements the ai.Model interface for Google Gemini.
type Provider struct {
opts ai.Options
}
// NewProvider creates a new Gemini provider.
func NewProvider(opts ...ai.Option) *Provider {
options := ai.NewOptions(opts...)
if options.Model == "" {
options.Model = "gemini-2.5-flash"
}
if options.BaseURL == "" {
options.BaseURL = "https://generativelanguage.googleapis.com"
}
return &Provider{opts: options}
}
func (p *Provider) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&p.opts)
}
return nil
}
func (p *Provider) Options() ai.Options { return p.opts }
func (p *Provider) String() string { return "gemini" }
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
var tools []map[string]any
for _, t := range req.Tools {
tools = append(tools, map[string]any{
"name": t.Name,
"description": t.Description,
"parameters": map[string]any{
"type": "object",
"properties": t.Properties,
},
})
}
contents := []map[string]any{
{"role": "user", "parts": []map[string]any{{"text": req.Prompt}}},
}
apiReq := map[string]any{
"contents": contents,
}
if req.SystemPrompt != "" {
apiReq["system_instruction"] = map[string]any{
"parts": []map[string]any{{"text": req.SystemPrompt}},
}
}
if len(tools) > 0 {
apiReq["tools"] = []map[string]any{
{"functionDeclarations": tools},
}
}
resp, rawParts, err := p.callAPI(ctx, apiReq)
if err != nil {
return nil, err
}
if len(resp.ToolCalls) == 0 {
return resp, nil
}
if p.opts.ToolHandler != nil {
var resultParts []map[string]any
for _, tc := range resp.ToolCalls {
result := p.opts.ToolHandler(ctx, tc).Value
resultParts = append(resultParts, map[string]any{
"functionResponse": map[string]any{
"name": tc.Name,
"id": tc.ID,
"response": result,
},
})
}
followUpContents := append(contents,
map[string]any{"role": "model", "parts": rawParts},
map[string]any{"role": "user", "parts": resultParts},
)
followUpReq := map[string]any{
"contents": followUpContents,
}
if req.SystemPrompt != "" {
followUpReq["system_instruction"] = map[string]any{
"parts": []map[string]any{{"text": req.SystemPrompt}},
}
}
followUpResp, _, err := p.callAPI(ctx, followUpReq)
if err == nil && followUpResp.Reply != "" {
resp.Answer = followUpResp.Reply
}
}
return resp, nil
}
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("%w: gemini provider", ai.ErrStreamingUnsupported)
}
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, []map[string]any, error) {
reqBody, err := json.Marshal(req)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") +
"/v1beta/models/" + p.opts.Model + ":generateContent"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("x-goog-api-key", p.opts.APIKey)
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, nil, fmt.Errorf("API request failed: %w", err)
}
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var geminiResp struct {
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text"`
FunctionCall *functionCallPB `json:"functionCall"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
}
if err := json.Unmarshal(respBody, &geminiResp); err != nil {
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
}
if len(geminiResp.Candidates) == 0 {
return nil, nil, fmt.Errorf("no response from API")
}
parts := geminiResp.Candidates[0].Content.Parts
response := &ai.Response{}
var replyParts []string
var rawParts []map[string]any
for _, part := range parts {
if part.Text != "" {
replyParts = append(replyParts, part.Text)
rawParts = append(rawParts, map[string]any{"text": part.Text})
}
if part.FunctionCall != nil {
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
ID: part.FunctionCall.ID,
Name: part.FunctionCall.Name,
Input: part.FunctionCall.Args,
})
rawParts = append(rawParts, map[string]any{
"functionCall": map[string]any{
"id": part.FunctionCall.ID,
"name": part.FunctionCall.Name,
"args": part.FunctionCall.Args,
},
})
}
}
if len(replyParts) > 0 {
response.Reply = strings.Join(replyParts, "\n")
}
return response, rawParts, nil
}
type functionCallPB struct {
ID string `json:"id"`
Name string `json:"name"`
Args map[string]any `json:"args"`
}
-105
View File
@@ -1,105 +0,0 @@
package gemini
import (
"context"
"errors"
"testing"
"go-micro.dev/v6/ai"
)
func TestProvider_String(t *testing.T) {
p := NewProvider()
if p.String() != "gemini" {
t.Errorf("Expected provider name 'gemini', got '%s'", p.String())
}
}
func TestProvider_Init(t *testing.T) {
p := NewProvider()
err := p.Init(
ai.WithModel("gemini-2.0-flash"),
ai.WithAPIKey("test-key"),
ai.WithBaseURL("https://test.com"),
)
if err != nil {
t.Fatalf("Init failed: %v", err)
}
opts := p.Options()
if opts.Model != "gemini-2.0-flash" {
t.Errorf("Expected model 'gemini-2.0-flash', got '%s'", opts.Model)
}
if opts.APIKey != "test-key" {
t.Errorf("Expected API key 'test-key', got '%s'", opts.APIKey)
}
if opts.BaseURL != "https://test.com" {
t.Errorf("Expected base URL 'https://test.com', got '%s'", opts.BaseURL)
}
}
func TestProvider_Options(t *testing.T) {
p := NewProvider(
ai.WithModel("custom-model"),
ai.WithAPIKey("my-key"),
)
opts := p.Options()
if opts.Model != "custom-model" {
t.Errorf("Expected model 'custom-model', got '%s'", opts.Model)
}
if opts.APIKey != "my-key" {
t.Errorf("Expected API key 'my-key', got '%s'", opts.APIKey)
}
}
func TestProvider_Defaults(t *testing.T) {
p := NewProvider()
opts := p.Options()
if opts.Model != "gemini-2.5-flash" {
t.Errorf("Expected default model 'gemini-2.5-flash', got '%s'", opts.Model)
}
if opts.BaseURL != "https://generativelanguage.googleapis.com" {
t.Errorf("Expected default base URL 'https://generativelanguage.googleapis.com', got '%s'", opts.BaseURL)
}
}
func TestProvider_Generate_NoAPIKey(t *testing.T) {
p := NewProvider()
req := &ai.Request{
Prompt: "Hello",
SystemPrompt: "You are helpful",
}
_, err := p.Generate(context.Background(), req)
if err == nil {
t.Error("Expected error when API key is missing, got nil")
}
}
func TestProvider_Stream_NotImplemented(t *testing.T) {
p := NewProvider()
req := &ai.Request{
Prompt: "Hello",
}
_, err := p.Stream(context.Background(), req)
if !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
}
}
func TestProvider_Registration(t *testing.T) {
m := ai.New("gemini", ai.WithAPIKey("test"))
if m == nil {
t.Fatal("ai.New('gemini') returned nil — provider not registered")
}
if m.String() != "gemini" {
t.Errorf("Expected 'gemini', got '%s'", m.String())
}
}
-196
View File
@@ -1,196 +0,0 @@
// Package groq implements the Groq model provider.
//
// Groq provides ultra-fast inference for open-weight models via an
// OpenAI-compatible chat completions endpoint.
//
// Usage:
//
// import _ "go-micro.dev/v6/ai/groq"
//
// m := ai.New("groq",
// ai.WithAPIKey("your-api-key"),
// )
package groq
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/ai/internal/openaiapi"
)
func init() {
ai.Register("groq", func(opts ...ai.Option) ai.Model {
return NewProvider(opts...)
})
ai.RegisterStream("groq")
}
type Provider struct {
opts ai.Options
}
func NewProvider(opts ...ai.Option) *Provider {
options := ai.NewOptions(opts...)
if options.Model == "" {
options.Model = "llama-3.3-70b-versatile"
}
if options.BaseURL == "" {
options.BaseURL = "https://api.groq.com/openai"
}
return &Provider{opts: options}
}
func (p *Provider) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&p.opts)
}
return nil
}
func (p *Provider) Options() ai.Options { return p.opts }
func (p *Provider) String() string { return "groq" }
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
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},
{"role": "user", "content": req.Prompt},
}
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
}
if len(tools) > 0 {
apiReq["tools"] = tools
}
resp, rawMessage, err := p.callAPI(ctx, apiReq)
if err != nil {
return nil, err
}
if len(resp.ToolCalls) == 0 {
return resp, nil
}
if p.opts.ToolHandler != nil {
followUpMessages := append(messages, map[string]any{
"role": "assistant",
"content": rawMessage["content"],
"tool_calls": rawMessage["tool_calls"],
})
for _, tc := range resp.ToolCalls {
content := p.opts.ToolHandler(ctx, tc).Content
followUpMessages = append(followUpMessages, map[string]any{
"role": "tool",
"tool_call_id": tc.ID,
"content": content,
})
}
followUpResp, _, err := p.callAPI(ctx, map[string]any{
"model": p.opts.Model,
"messages": followUpMessages,
})
if err == nil && followUpResp.Reply != "" {
resp.Answer = followUpResp.Reply
}
}
return resp, nil
}
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return openaiapi.Stream(ctx, p.opts, req, "/v1/chat/completions")
}
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
reqBody, err := json.Marshal(req)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, nil, fmt.Errorf("API request failed: %w", err)
}
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var chatResp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
}
if len(chatResp.Choices) == 0 {
return nil, nil, fmt.Errorf("no response from API")
}
choice := chatResp.Choices[0]
response := &ai.Response{Reply: choice.Message.Content}
for _, tc := range choice.Message.ToolCalls {
var input map[string]any
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
input = map[string]any{}
}
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Input: input,
})
}
rawMessage := map[string]any{
"content": choice.Message.Content,
"tool_calls": choice.Message.ToolCalls,
}
return response, rawMessage, nil
}
-96
View File
@@ -1,96 +0,0 @@
package groq
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"go-micro.dev/v6/ai"
)
func TestProvider_String(t *testing.T) {
if NewProvider().String() != "groq" {
t.Errorf("got %q", NewProvider().String())
}
}
func TestProvider_Defaults(t *testing.T) {
opts := NewProvider().Options()
if opts.Model != "llama-3.3-70b-versatile" {
t.Errorf("default model = %q", opts.Model)
}
if opts.BaseURL != "https://api.groq.com/openai" {
t.Errorf("default base URL = %q", opts.BaseURL)
}
}
func TestProvider_Init(t *testing.T) {
p := NewProvider()
if err := p.Init(ai.WithModel("m"), ai.WithAPIKey("k")); err != nil {
t.Fatal(err)
}
if p.Options().Model != "m" || p.Options().APIKey != "k" {
t.Error("Init did not apply options")
}
}
func TestProvider_Generate_NoAPIKey(t *testing.T) {
if _, err := NewProvider().Generate(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
t.Error("expected error without API key")
}
}
func TestProvider_Stream(t *testing.T) {
var sawStream bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Fatalf("path = %s, want /v1/chat/completions", r.URL.Path)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
sawStream, _ = body["stream"].(bool)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer ts.Close()
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
if !sawStream {
t.Fatal("stream request did not set stream=true")
}
first, err := stream.Recv()
if err != nil || first.Reply != "hel" {
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
}
second, err := stream.Recv()
if err != nil || second.Reply != "lo" {
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
}
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
t.Fatalf("final error = %v, want EOF", err)
}
}
func TestProvider_Registration(t *testing.T) {
m := ai.New("groq", ai.WithAPIKey("test"))
if m == nil {
t.Fatal("provider not registered")
}
if m.String() != "groq" {
t.Errorf("got %q", m.String())
}
}
-45
View File
@@ -1,45 +0,0 @@
package ai
// History is a convenience for accumulating conversation messages
// with automatic truncation. Use it to build Request.Messages for
// multi-turn conversations.
//
// hist := ai.NewHistory(50)
// hist.Add("user", "hello")
// resp, _ := m.Generate(ctx, &ai.Request{Messages: hist.Messages(), Prompt: "next"})
// hist.Add("assistant", resp.Reply)
type History struct {
messages []Message
limit int
}
// NewHistory creates an empty History. limit controls the maximum
// number of messages retained (0 = unlimited).
func NewHistory(limit int) *History {
return &History{limit: limit}
}
// Add appends a message and truncates if over limit.
func (h *History) Add(role string, content any) {
h.messages = append(h.messages, Message{Role: role, Content: content})
if h.limit > 0 && len(h.messages) > h.limit {
h.messages = h.messages[len(h.messages)-h.limit:]
}
}
// Messages returns a copy of the accumulated messages.
func (h *History) Messages() []Message {
out := make([]Message, len(h.messages))
copy(out, h.messages)
return out
}
// Len returns the number of messages.
func (h *History) Len() int {
return len(h.messages)
}
// Reset clears all messages.
func (h *History) Reset() {
h.messages = nil
}
-62
View File
@@ -1,62 +0,0 @@
package ai
import "testing"
func TestHistory_Add(t *testing.T) {
h := NewHistory(0)
h.Add("user", "hello")
h.Add("assistant", "hi")
if h.Len() != 2 {
t.Errorf("len = %d, want 2", h.Len())
}
msgs := h.Messages()
if msgs[0].Role != "user" || msgs[0].Content != "hello" {
t.Errorf("first = %+v", msgs[0])
}
if msgs[1].Role != "assistant" || msgs[1].Content != "hi" {
t.Errorf("second = %+v", msgs[1])
}
}
func TestHistory_Truncation(t *testing.T) {
h := NewHistory(3)
for _, m := range []string{"a", "b", "c", "d", "e"} {
h.Add("user", m)
}
if h.Len() != 3 {
t.Errorf("len = %d, want 3", h.Len())
}
if h.Messages()[0].Content != "c" {
t.Errorf("first retained = %+v", h.Messages()[0])
}
}
func TestHistory_Reset(t *testing.T) {
h := NewHistory(0)
h.Add("user", "hello")
h.Reset()
if h.Len() != 0 {
t.Errorf("len after reset = %d", h.Len())
}
}
func TestHistory_SnapshotIsCopy(t *testing.T) {
h := NewHistory(0)
h.Add("user", "hello")
msgs := h.Messages()
msgs[0].Content = "mutated"
if h.Messages()[0].Content == "mutated" {
t.Error("snapshot returned reference, not copy")
}
}
func TestHistory_Unlimited(t *testing.T) {
h := NewHistory(0)
for i := 0; i < 100; i++ {
h.Add("user", "msg")
}
if h.Len() != 100 {
t.Errorf("len = %d, want 100", h.Len())
}
}
-65
View File
@@ -1,65 +0,0 @@
package ai
import "context"
// ImageModel provides an interface for image generation providers.
// Providers that support image generation implement this alongside
// or instead of Model. Use NewImage to construct, or type-assert
// a provider that implements both:
//
// p := atlascloud.NewProvider(ai.WithAPIKey(key))
// if ig, ok := p.(ai.ImageModel); ok {
// resp, _ := ig.GenerateImage(ctx, req)
// }
type ImageModel interface {
GenerateImage(ctx context.Context, req *ImageRequest, opts ...GenerateOption) (*ImageResponse, error)
String() string
}
// ImageRequest describes what image to generate.
type ImageRequest struct {
// Prompt is the text description of the image to generate.
Prompt string
// Model overrides the provider's default image model.
Model string
// Size of the generated image (e.g. "1024x1024"). Provider-specific.
Size string
// N is the number of images to generate. Defaults to 1.
N int
// Quality controls generation quality. Provider-specific (e.g. "low", "medium", "high").
Quality string
// OutputFormat sets the image format (e.g. "png", "jpeg"). Provider-specific.
OutputFormat string
}
// ImageResponse holds the generated images.
type ImageResponse struct {
Images []Image
}
// Image is a single generated image, returned as a URL, base64 data, or both
// depending on the provider and request options.
type Image struct {
// URL is a remote URL where the image can be fetched.
URL string
// Base64 is the base64-encoded image data.
Base64 string
}
// NewImageFunc creates a new ImageModel instance.
type NewImageFunc func(...Option) ImageModel
var imageProviders = make(map[string]NewImageFunc)
// RegisterImage registers an image generation provider.
func RegisterImage(name string, fn NewImageFunc) {
imageProviders[name] = fn
}
// NewImage creates a new ImageModel instance based on the provider name.
func NewImage(provider string, opts ...Option) ImageModel {
if fn, ok := imageProviders[provider]; ok {
return fn(opts...)
}
return nil
}
-117
View File
@@ -1,117 +0,0 @@
package openaiapi
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"go-micro.dev/v6/ai"
)
// Stream opens an OpenAI-compatible chat completions SSE stream.
func Stream(ctx context.Context, opts ai.Options, req *ai.Request, basePath string) (ai.Stream, error) {
messages := []map[string]any{{"role": "system", "content": req.SystemPrompt}}
for _, m := range req.Messages {
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
}
apiReq := map[string]any{
"model": opts.Model,
"messages": messages,
"stream": true,
"stream_options": map[string]any{"include_usage": true},
}
if opts.MaxTokens > 0 {
apiReq["max_tokens"] = opts.MaxTokens
}
reqBody, err := json.Marshal(apiReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
}
apiURL := strings.TrimRight(opts.BaseURL, "/") + basePath
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("Authorization", "Bearer "+opts.APIKey)
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("stream API request failed: %w", err)
}
if httpResp.StatusCode != http.StatusOK {
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
return nil, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(respBody))
}
return &StreamReader{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
}
// StreamReader reads OpenAI-compatible server-sent event chunks.
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, ":") {
continue
}
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "[DONE]" {
return nil, io.EOF
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
}
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
return &ai.Response{Reply: chunk.Choices[0].Delta.Content}, nil
}
if chunk.Usage != nil {
return &ai.Response{Usage: ai.Usage{
InputTokens: chunk.Usage.PromptTokens,
OutputTokens: chunk.Usage.CompletionTokens,
TotalTokens: chunk.Usage.TotalTokens,
}}, nil
}
}
if err := s.scanner.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}
func (s *StreamReader) Close() error {
if s.closed {
return nil
}
s.closed = true
return s.body.Close()
}
-196
View File
@@ -1,196 +0,0 @@
// Package minimax implements the MiniMax model provider.
//
// MiniMax offers its flagship MiniMax-M3 model via an OpenAI-compatible
// chat completions endpoint.
//
// Usage:
//
// import _ "go-micro.dev/v6/ai/minimax"
//
// m := ai.New("minimax",
// ai.WithAPIKey("your-api-key"),
// )
package minimax
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/ai/internal/openaiapi"
)
func init() {
ai.Register("minimax", func(opts ...ai.Option) ai.Model {
return NewProvider(opts...)
})
ai.RegisterStream("minimax")
}
type Provider struct {
opts ai.Options
}
func NewProvider(opts ...ai.Option) *Provider {
options := ai.NewOptions(opts...)
if options.Model == "" {
options.Model = "MiniMax-M3"
}
if options.BaseURL == "" {
options.BaseURL = "https://api.minimax.io"
}
return &Provider{opts: options}
}
func (p *Provider) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&p.opts)
}
return nil
}
func (p *Provider) Options() ai.Options { return p.opts }
func (p *Provider) String() string { return "minimax" }
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
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},
{"role": "user", "content": req.Prompt},
}
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
}
if len(tools) > 0 {
apiReq["tools"] = tools
}
resp, rawMessage, err := p.callAPI(ctx, apiReq)
if err != nil {
return nil, err
}
if len(resp.ToolCalls) == 0 {
return resp, nil
}
if p.opts.ToolHandler != nil {
followUpMessages := append(messages, map[string]any{
"role": "assistant",
"content": rawMessage["content"],
"tool_calls": rawMessage["tool_calls"],
})
for _, tc := range resp.ToolCalls {
content := p.opts.ToolHandler(ctx, tc).Content
followUpMessages = append(followUpMessages, map[string]any{
"role": "tool",
"tool_call_id": tc.ID,
"content": content,
})
}
followUpResp, _, err := p.callAPI(ctx, map[string]any{
"model": p.opts.Model,
"messages": followUpMessages,
})
if err == nil && followUpResp.Reply != "" {
resp.Answer = followUpResp.Reply
}
}
return resp, nil
}
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return openaiapi.Stream(ctx, p.opts, req, "/v1/chat/completions")
}
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
reqBody, err := json.Marshal(req)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, nil, fmt.Errorf("API request failed: %w", err)
}
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var chatResp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
}
if len(chatResp.Choices) == 0 {
return nil, nil, fmt.Errorf("no response from API")
}
choice := chatResp.Choices[0]
response := &ai.Response{Reply: choice.Message.Content}
for _, tc := range choice.Message.ToolCalls {
var input map[string]any
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
input = map[string]any{}
}
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Input: input,
})
}
rawMessage := map[string]any{
"content": choice.Message.Content,
"tool_calls": choice.Message.ToolCalls,
}
return response, rawMessage, nil
}
-96
View File
@@ -1,96 +0,0 @@
package minimax
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"go-micro.dev/v6/ai"
)
func TestProvider_String(t *testing.T) {
if NewProvider().String() != "minimax" {
t.Errorf("got %q", NewProvider().String())
}
}
func TestProvider_Defaults(t *testing.T) {
opts := NewProvider().Options()
if opts.Model != "MiniMax-M3" {
t.Errorf("default model = %q", opts.Model)
}
if opts.BaseURL != "https://api.minimax.io" {
t.Errorf("default base URL = %q", opts.BaseURL)
}
}
func TestProvider_Init(t *testing.T) {
p := NewProvider()
if err := p.Init(ai.WithModel("m"), ai.WithAPIKey("k")); err != nil {
t.Fatal(err)
}
if p.Options().Model != "m" || p.Options().APIKey != "k" {
t.Error("Init did not apply options")
}
}
func TestProvider_Generate_NoAPIKey(t *testing.T) {
if _, err := NewProvider().Generate(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
t.Error("expected error without API key")
}
}
func TestProvider_Stream(t *testing.T) {
var sawStream bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Fatalf("path = %s, want /v1/chat/completions", r.URL.Path)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
sawStream, _ = body["stream"].(bool)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer ts.Close()
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
if !sawStream {
t.Fatal("stream request did not set stream=true")
}
first, err := stream.Recv()
if err != nil || first.Reply != "hel" {
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
}
second, err := stream.Recv()
if err != nil || second.Reply != "lo" {
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
}
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
t.Fatalf("final error = %v, want EOF", err)
}
}
func TestProvider_Registration(t *testing.T) {
m := ai.New("minimax", ai.WithAPIKey("test"))
if m == nil {
t.Fatal("provider not registered")
}
if m.String() != "minimax" {
t.Errorf("got %q", m.String())
}
}

Some files were not shown because too many files have changed in this diff Show More