Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8e5871521 | |||
| c8d71e051a | |||
| 6734f3800b |
@@ -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
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
github: asim
|
||||
custom: ["https://go-micro.dev/support"]
|
||||
@@ -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/WeMU5AGxD)
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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/WeMU5AGxD)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -1,42 +0,0 @@
|
||||
name: Auto-merge Codex PRs
|
||||
|
||||
# Part of the autonomous improvement loop (internal/docs/CONTINUOUS_IMPROVEMENT.md).
|
||||
# Merges Codex's PRs once CI is green — no human involvement; CI (build, test,
|
||||
# golangci-lint, harnesses) is the only gate. Scoped to PRs that are BOTH
|
||||
# codex-labelled AND from a codex/* branch, so nothing else can auto-merge.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "*/15 * * * *" # sweep every 15 min
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: auto-merge-codex
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Merge green Codex PRs
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh pr list --repo "$REPO" --label codex --state open \
|
||||
--json number,headRefName \
|
||||
--jq '.[] | select(.headRefName | startswith("codex/")) | .number' \
|
||||
| while read -r pr; do
|
||||
[ -z "$pr" ] && continue
|
||||
if gh pr checks "$pr" --repo "$REPO" >/dev/null 2>&1; then
|
||||
echo "Checks green on #$pr — merging."
|
||||
gh pr merge "$pr" --repo "$REPO" --squash --delete-branch \
|
||||
|| echo "skip #$pr (not mergeable — conflicts?)"
|
||||
else
|
||||
echo "skip #$pr (checks pending/failing)"
|
||||
fi
|
||||
done
|
||||
@@ -1,62 +0,0 @@
|
||||
name: Continuous Improvement
|
||||
|
||||
# Durable backbone for the autonomous improvement loop
|
||||
# (see internal/docs/CONTINUOUS_IMPROVEMENT.md).
|
||||
#
|
||||
# A Claude Max subscription provides no API key for CI, so the loop is driven by
|
||||
# Codex rather than Claude Code: on a cadence this opens a fresh tracking issue and
|
||||
# posts an @codex instruction on it, and Codex runs one improvement increment + opens
|
||||
# a PR. (See the per-issue rationale below.)
|
||||
#
|
||||
# Codex does NOT respond to comments authored by the github-actions bot, so the
|
||||
# dispatch is GATED on a CODEX_TRIGGER_TOKEN secret (a PAT for a user account Codex
|
||||
# follows). Until that secret is set the workflow runs but no-ops — this avoids
|
||||
# piling up @codex comments that Codex silently ignores. The moment the secret is
|
||||
# added the loop activates with no further change.
|
||||
#
|
||||
# Each run opens a FRESH issue and dispatches Codex there, rather than re-commenting
|
||||
# on one tracker issue. Codex derives its PR branch name from the triggering issue's
|
||||
# context, so repeated dispatches on a single issue all collapse onto one branch name
|
||||
# (codex/github-mention-<that-issue-slug>) — the first increment opens a PR, the rest
|
||||
# collide on the occupied branch and silently fail to open one. A unique issue per
|
||||
# run gives each increment its own branch and a clean PR. The dispatch asks Codex to
|
||||
# "Closes #<issue>" so each tracking issue auto-closes when its PR merges.
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
schedule:
|
||||
- cron: "29 * * * *" # hourly, off-minute (tune as needed)
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: continuous-improvement
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Open a fresh increment issue and dispatch Codex
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
|
||||
HAS_TRIGGER_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
if [ "$HAS_TRIGGER_TOKEN" != "true" ]; then
|
||||
echo "CODEX_TRIGGER_TOKEN is not set — skipping dispatch."
|
||||
echo "Codex ignores comments from the github-actions bot, so posting now"
|
||||
echo "would only create noise. Add a CODEX_TRIGGER_TOKEN secret (a PAT for"
|
||||
echo "a user account Codex follows) to activate the loop."
|
||||
exit 0
|
||||
fi
|
||||
# A unique issue per run → Codex derives a unique branch → no collisions.
|
||||
ISSUE_URL=$(gh issue create --repo "$REPO" \
|
||||
--title "Continuous improvement increment #$RUN_NUMBER" \
|
||||
--body "Autonomous continuous-improvement increment. North Star: internal/docs/THESIS.md; charter: internal/docs/CONTINUOUS_IMPROVEMENT.md. Tracker: #3024.")
|
||||
ISSUE_NUM="${ISSUE_URL##*/}"
|
||||
echo "Opened issue #$ISSUE_NUM — dispatching Codex."
|
||||
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
|
||||
"@codex Run one continuous-improvement increment per internal/docs/CONTINUOUS_IMPROVEMENT.md, aligned to the North Star in internal/docs/THESIS.md (the holistic services → agents → workflows lifecycle). Pick the single highest-value roadmap/issue/improvement-radar item that advances that thesis, implement it, and verify \`go build ./...\`, \`go test ./...\`, and \`golangci-lint run ./...\`. Then 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). Run \`git push -u origin HEAD\` then \`gh pr create --base master --title \"<title>\" --body \"<body, including 'Closes #$ISSUE_NUM'>\"\`; the gh CLI is installed and authenticated and origin points to $REPO. One concern per PR; stay out of brand/positioning copy and breaking public API."
|
||||
@@ -1,59 +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 any live providers
|
||||
# whose API key secrets are configured.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
schedule:
|
||||
- cron: "17 6 * * *" # daily, so the world is exercised even without changes
|
||||
workflow_dispatch:
|
||||
|
||||
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: Universe end-to-end (asserts; exits non-zero on failure)
|
||||
run: go run ./internal/harness/universe
|
||||
- name: Agent-flow harness
|
||||
run: go run ./internal/harness/agent-flow
|
||||
- name: Plan-delegate harness
|
||||
run: go run ./internal/harness/plan-delegate
|
||||
|
||||
harness-live:
|
||||
name: Provider harnesses (live LLM, if keys present)
|
||||
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 configured 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 }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
ATLASCLOUD_API_KEY: ${{ secrets.ATLASCLOUD_API_KEY }}
|
||||
run: go run ./internal/harness/provider-conformance
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -1,74 +0,0 @@
|
||||
name: Run Tests
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
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
|
||||
|
||||
@@ -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
-34
@@ -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,37 +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
|
||||
|
||||
# Local Jekyll / Bundler artifacts
|
||||
internal/website/.bundle/
|
||||
internal/website/_site/
|
||||
internal/website/.jekyll-cache/
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
Vendored
-137
@@ -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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,15 +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.
|
||||
3. Check `git status --short` and review the diff before finishing.
|
||||
4. Stage the intended files and create a local git commit on the current branch.
|
||||
5. Use the Codex `make_pr` tool to open the pull request with a concise title and a body that summarizes the change and testing.
|
||||
|
||||
Do not just say that a PR was opened. If local changes exist, the task is not complete until the changes are committed and the `make_pr` tool has been called. A GitHub token in the shell environment is not a substitute for the Codex `make_pr` tool in this environment.
|
||||
-126
@@ -1,126 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Go Micro are documented here.
|
||||
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/). Go Micro uses
|
||||
calendar-based versions (YYYY.MM) for the AI-native era.
|
||||
|
||||
---
|
||||
|
||||
## [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. v1 is the synchronous JSON-RPC binding (`message/send`, `tasks/get`, card discovery); streaming and push notifications are advertised as unsupported. (`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)._
|
||||
@@ -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`)
|
||||
@@ -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.
|
||||
```
|
||||
-219
@@ -1,219 +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.
|
||||
|
||||
## 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
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||
@@ -1,96 +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 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 end-to-end harnesses"
|
||||
@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 end-to-end harnesses (deterministic, mock LLM — no API key).
|
||||
# The universe harness exits non-zero on assertion failure.
|
||||
harness:
|
||||
go run ./internal/harness/universe
|
||||
go run ./internal/harness/agent-flow
|
||||
go run ./internal/harness/plan-delegate
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,426 +1,49 @@
|
||||
# Go Micro [](https://pkg.go.dev/go-micro.dev/v6?tab=doc) [](https://goreportcard.com/report/github.com/go-micro/go-micro)
|
||||
# Go Micro [](https://opensource.org/licenses/Apache-2.0) [](https://godoc.org/github.com/micro/go-micro) [](https://travis-ci.org/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.
|
||||
|
||||
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 gives you that 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.
|
||||
## Overview
|
||||
|
||||
## Sponsors
|
||||
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.
|
||||
|
||||
<a href="https://go-micro.dev/blog/3"><img src="https://upload.wikimedia.org/wikipedia/commons/7/78/Anthropic_logo.svg" height="26" /></a>
|
||||
|
||||
<a href="https://go-micro.dev/blog/29"><img src="https://upload.wikimedia.org/wikipedia/commons/4/4d/OpenAI_Logo.svg" height="26" /></a>
|
||||
|
||||
<a href="https://go-micro.dev/blog/8"><img src="https://www.atlascloud.ai/logo.svg" height="26" /></a>
|
||||
<img src="https://micro.mu/docs/images/go-micro.svg" />
|
||||
|
||||
**Want to support Go Micro and see your logo here?** [Become a sponsor](https://discord.gg/WeMU5AGxD) — reach out on Discord.
|
||||
Plugins are available at [github.com/micro/go-plugins](https://github.com/micro/go-plugins).
|
||||
|
||||
## 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)
|
||||
- [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@v6
|
||||
```
|
||||
|
||||
### 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"}'
|
||||
```
|
||||
|
||||
### 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.AgentMemory(micro.NewInMemory(50)), // memory — default is store-backed & durable
|
||||
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`. **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, …) |
|
||||
| 7 LLM providers | Anthropic, OpenAI, Gemini, Groq, Mistral, Together, Atlas 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 | `Llama-3.3-70B-Instruct-Turbo` |
|
||||
| Atlas Cloud | `llama-3.3-70b` |
|
||||
|
||||
```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
|
||||
- [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)
|
||||
- [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
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Go Micro [](https://opensource.org/licenses/Apache-2.0) [](https://godoc.org/github.com/micro/go-micro) [](https://travis-ci.org/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/)。
|
||||
|
||||
-78
@@ -1,78 +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), 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** — `ai.Stream` + A2A `message/stream`, end to end.
|
||||
- **Agent observability** — `RunInfo` → OpenTelemetry spans.
|
||||
|
||||
## Later
|
||||
|
||||
- Memory management (summarization, retrieval/RAG); human-in-the-loop pause/resume;
|
||||
x402 live-facilitator conformance and paid remote tools with spend caps; A2A
|
||||
streaming, push notifications, and multi-turn tasks.
|
||||
|
||||
## 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/WeMU5AGxD). 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
@@ -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/WeMU5AGxD
|
||||
- Email: support@go-micro.dev
|
||||
|
||||
-29
@@ -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.
|
||||
+1
-364
@@ -1,365 +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"
|
||||
|
||||
"github.com/google/uuid"
|
||||
pb "go-micro.dev/v6/agent/proto"
|
||||
"go-micro.dev/v6/ai"
|
||||
"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/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)
|
||||
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
|
||||
}
|
||||
|
||||
// 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() {
|
||||
var modelOpts []ai.Option
|
||||
modelOpts = append(modelOpts, ai.WithAPIKey(a.opts.APIKey))
|
||||
if a.opts.Model != "" {
|
||||
modelOpts = append(modelOpts, ai.WithModel(a.opts.Model))
|
||||
}
|
||||
|
||||
a.tools = ai.NewTools(a.opts.Registry, ai.ToolClient(a.opts.Client))
|
||||
modelOpts = append(modelOpts, ai.WithToolHandler(a.toolHandler()))
|
||||
a.model = ai.New(a.opts.Provider, modelOpts...)
|
||||
if a.opts.TraceProvider != nil && a.model != nil {
|
||||
a.model = a.tracedModel(a.model)
|
||||
}
|
||||
|
||||
// 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)
|
||||
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) {
|
||||
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)
|
||||
a.steps = 0
|
||||
a.calls = map[string]int{}
|
||||
|
||||
// Correlate this run's tool calls and surface lineage to wrappers.
|
||||
a.runID = uuid.New().String()
|
||||
ctx = ai.WithRunInfo(ctx, ai.RunInfo{
|
||||
RunID: a.runID,
|
||||
ParentID: a.parentRunID,
|
||||
Agent: a.opts.Name,
|
||||
})
|
||||
ctx, endRun := a.startRun(ctx, message)
|
||||
defer func() { endRun(err) }()
|
||||
|
||||
resp, err := ai.GenerateWithRetry(ctx, a.model, &ai.Request{
|
||||
Prompt: message,
|
||||
SystemPrompt: a.buildPrompt(),
|
||||
Tools: toolList,
|
||||
Messages: a.mem.Messages(),
|
||||
}, ai.GeneratePolicy{
|
||||
Timeout: a.opts.ModelTimeout,
|
||||
MaxAttempts: a.opts.ModelMaxAttempts,
|
||||
Backoff: a.opts.ModelRetryBackoff,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return &Response{
|
||||
Reply: reply,
|
||||
ToolCalls: resp.ToolCalls,
|
||||
Agent: a.opts.Name,
|
||||
RunID: a.runID,
|
||||
ParentID: a.parentRunID,
|
||||
}, 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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rsp.Reply = resp.Reply
|
||||
rsp.Agent = resp.Agent
|
||||
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.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.NewAgentHandler(card, func(ctx context.Context, text string) (string, error) {
|
||||
resp, err := a.Ask(ctx, text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.Reply, nil
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
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 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
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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: 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 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.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 → base.
|
||||
h := a.baseHandler()
|
||||
h = a.approveWrap(h)
|
||||
h = a.loopWrap(h)
|
||||
h = a.stepWrap(h)
|
||||
h = a.planWrap(h)
|
||||
h = a.traceTool(h)
|
||||
for i := len(a.opts.wrappers) - 1; i >= 0; i-- {
|
||||
h = a.opts.wrappers[i](h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// 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 == 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)
|
||||
}
|
||||
return next(ctx, call)
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
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
|
||||
}
|
||||
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)}
|
||||
}
|
||||
|
||||
// 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),
|
||||
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
|
||||
}
|
||||
@@ -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) != 2 {
|
||||
t.Fatalf("builtinTools() = %d tools, want 2", len(tools))
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, tl := range tools {
|
||||
names[tl.Name] = true
|
||||
}
|
||||
if !names[toolPlan] || !names[toolDelegate] {
|
||||
t.Errorf("builtin tools = %v, want plan 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) != 2 {
|
||||
t.Fatalf("Builtins() returned %d tools, want 2", 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(®istry.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(®istry.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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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)
|
||||
|
||||
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) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *fakeModel) String() string { return "fake" }
|
||||
|
||||
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(®istry.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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"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()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func (m *storeMemory) Add(role, content string) {
|
||||
m.mu.Lock()
|
||||
m.hist.Add(role, content)
|
||||
m.mu.Unlock()
|
||||
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.mu.Unlock()
|
||||
m.save()
|
||||
}
|
||||
|
||||
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 msgs []ai.Message
|
||||
if err := json.Unmarshal(recs[0].Value, &msgs); err != nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
for _, msg := range msgs {
|
||||
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(m.hist.Messages())
|
||||
m.mu.Unlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = m.store.Write(&store.Record{Key: m.key, Value: data})
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/client"
|
||||
"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
|
||||
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
|
||||
|
||||
// Memory is the agent's conversation memory. Nil = the default
|
||||
// store-backed memory (durable across restarts).
|
||||
Memory Memory
|
||||
|
||||
// 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
|
||||
|
||||
// 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,
|
||||
// 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 }
|
||||
}
|
||||
|
||||
// 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 }
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }
|
||||
}
|
||||
|
||||
// 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. When nil,
|
||||
// agent tracing and run timeline recording are disabled.
|
||||
func TraceProvider(tp trace.TracerProvider) Option {
|
||||
return func(o *Options) { o.TraceProvider = tp }
|
||||
}
|
||||
-295
@@ -1,295 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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"
|
||||
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"
|
||||
AttrToolName = "agent.tool.name"
|
||||
AttrDelegate = "agent.delegate"
|
||||
AttrGuardrailBlock = "agent.guardrail.block"
|
||||
AttrRefusal = "agent.refusal"
|
||||
)
|
||||
|
||||
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"`
|
||||
LatencyMS int64 `json:"latency_ms,omitempty"`
|
||||
Tokens Usage `json:"tokens,omitempty"`
|
||||
Refused string `json:"refused,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type Usage = ai.Usage
|
||||
|
||||
// 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"`
|
||||
Events int `json:"events"`
|
||||
LastKind string `json:"last_kind,omitempty"`
|
||||
LastError string `json:"last_error,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)) {
|
||||
if a.opts.TraceProvider == nil {
|
||||
return ctx, func(error) {}
|
||||
}
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
ctx, span := a.tracer().Start(ctx, spanNameRun, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(
|
||||
attribute.String(AttrRunID, info.RunID), attribute.String(AttrParentRunID, info.ParentID), attribute.String(AttrAgentName, info.Agent)))
|
||||
start := time.Now()
|
||||
a.recordSpanEvent(span, RunEvent{Time: start, RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "run", Name: message})
|
||||
return ctx, func(err error) {
|
||||
latency := time.Since(start).Milliseconds()
|
||||
span.SetAttributes(attribute.Int64(AttrLatencyMS, latency))
|
||||
if err != nil {
|
||||
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()})
|
||||
} 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) {
|
||||
if m.a.opts.TraceProvider == nil {
|
||||
return m.Model.Generate(ctx, req, opts...)
|
||||
}
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
provider := m.String()
|
||||
model := m.Options().Model
|
||||
ctx, span := m.a.tracer().Start(ctx, spanNameModelCall, trace.WithAttributes(attribute.String(AttrProvider, provider), attribute.String(AttrModel, model)))
|
||||
start := time.Now()
|
||||
resp, err := m.Model.Generate(ctx, req, opts...)
|
||||
dur := time.Since(start).Milliseconds()
|
||||
attrs := []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
|
||||
usage := ai.Usage{}
|
||||
if resp != nil {
|
||||
usage = resp.Usage
|
||||
attrs = appendUsage(attrs, usage)
|
||||
}
|
||||
span.SetAttributes(attrs...)
|
||||
if err != nil {
|
||||
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, LatencyMS: dur, Tokens: usage}
|
||||
if err != nil {
|
||||
e.Error = err.Error()
|
||||
}
|
||||
m.a.recordSpanEvent(span, e)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
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 {
|
||||
if a.opts.TraceProvider == nil {
|
||||
return next
|
||||
}
|
||||
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
ctx, span := a.tracer().Start(ctx, spanNameToolCall, trace.WithAttributes(attribute.String(AttrToolName, call.Name), attribute.Bool(AttrDelegate, call.Name == toolDelegate)))
|
||||
start := time.Now()
|
||||
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))
|
||||
}
|
||||
span.SetAttributes(attrs...)
|
||||
resErr := resultError(res)
|
||||
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})
|
||||
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 (a *agentImpl) recordSpanEvent(span trace.Span, e RunEvent) {
|
||||
if sc := span.SpanContext(); sc.IsValid() {
|
||||
e.TraceID = sc.TraceID().String()
|
||||
e.SpanID = sc.SpanID().String()
|
||||
}
|
||||
a.recordRunEvent(e)
|
||||
}
|
||||
|
||||
func (a *agentImpl) recordRunEvent(e RunEvent) {
|
||||
if a.opts.TraceProvider == nil || 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) {
|
||||
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,
|
||||
Events: len(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.Error != "" {
|
||||
summary.LastError = e.Error
|
||||
}
|
||||
}
|
||||
summaries = append(summaries, summary)
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/store"
|
||||
"go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
)
|
||||
|
||||
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 {
|
||||
_ = 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}
|
||||
for _, s := range spans {
|
||||
if _, ok := want[s.Name()]; ok {
|
||||
want[s.Name()] = true
|
||||
}
|
||||
}
|
||||
for name, seen := range want {
|
||||
if !seen {
|
||||
t.Fatalf("span %s not emitted; got %d spans", name, len(spans))
|
||||
}
|
||||
}
|
||||
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].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 TestAgentOpenTelemetryNoopWhenUnconfigured(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.Fatalf("expected no run timeline without TraceProvider, got %v", keys)
|
||||
}
|
||||
if _, ok := a.(*agentImpl).model.(*tracedModel); ok {
|
||||
t.Fatal("model should not be wrapped when TraceProvider is nil")
|
||||
}
|
||||
}
|
||||
|
||||
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: "error", Error: "boom"},
|
||||
}
|
||||
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].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 != 2 || got[1].LastKind != "error" || got[1].LastError != "boom" {
|
||||
t.Fatalf("unexpected run-b summary: %#v", got[1])
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.21.12
|
||||
// source: 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"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ChatRequest) Reset() {
|
||||
*x = ChatRequest{}
|
||||
mi := &file_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_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_proto_agent_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *ChatRequest) GetMessage() string {
|
||||
if x != nil {
|
||||
return x.Message
|
||||
}
|
||||
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"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ChatResponse) Reset() {
|
||||
*x = ChatResponse{}
|
||||
mi := &file_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_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_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
|
||||
}
|
||||
|
||||
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_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_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_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_proto_agent_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_proto_agent_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x11proto/agent.proto\x12\x05agent\"'\n" +
|
||||
"\vChatRequest\x12\x18\n" +
|
||||
"\amessage\x18\x01 \x01(\tR\amessage\"j\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\"\\\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_proto_agent_proto_rawDescOnce sync.Once
|
||||
file_proto_agent_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
func file_proto_agent_proto_rawDescGZIP() []byte {
|
||||
file_proto_agent_proto_rawDescOnce.Do(func() {
|
||||
file_proto_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_agent_proto_rawDesc), len(file_proto_agent_proto_rawDesc)))
|
||||
})
|
||||
return file_proto_agent_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_proto_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
|
||||
var file_proto_agent_proto_goTypes = []any{
|
||||
(*ChatRequest)(nil), // 0: agent.ChatRequest
|
||||
(*ChatResponse)(nil), // 1: agent.ChatResponse
|
||||
(*ToolCall)(nil), // 2: agent.ToolCall
|
||||
}
|
||||
var file_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_proto_agent_proto_init() }
|
||||
func file_proto_agent_proto_init() {
|
||||
if File_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_proto_agent_proto_rawDesc), len(file_proto_agent_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 3,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
GoTypes: file_proto_agent_proto_goTypes,
|
||||
DependencyIndexes: file_proto_agent_proto_depIdxs,
|
||||
MessageInfos: file_proto_agent_proto_msgTypes,
|
||||
}.Build()
|
||||
File_proto_agent_proto = out.File
|
||||
file_proto_agent_proto_goTypes = nil
|
||||
file_proto_agent_proto_depIdxs = nil
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
// Code generated by protoc-gen-micro. DO NOT EDIT.
|
||||
// source: 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)
|
||||
}
|
||||
@@ -1,27 +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;
|
||||
}
|
||||
|
||||
message ChatResponse {
|
||||
string reply = 1;
|
||||
string agent = 2;
|
||||
repeated ToolCall tool_calls = 3;
|
||||
}
|
||||
|
||||
message ToolCall {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string input = 3;
|
||||
string result = 4;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
-351
@@ -1,351 +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)
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
@@ -1,272 +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": 8192,
|
||||
"system": req.SystemPrompt,
|
||||
"messages": []map[string]any{
|
||||
{"role": "user", "content": req.Prompt},
|
||||
},
|
||||
}
|
||||
|
||||
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 := []map[string]any{
|
||||
{"role": "user", "content": req.Prompt},
|
||||
{"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": 8192,
|
||||
"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("streaming not yet implemented for anthropic provider")
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 err == nil {
|
||||
t.Error("Expected error for unimplemented streaming, got nil")
|
||||
}
|
||||
}
|
||||
@@ -1,489 +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 (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"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...)
|
||||
})
|
||||
}
|
||||
|
||||
// Provider implements the ai.Model interface for Atlas Cloud.
|
||||
type Provider struct {
|
||||
opts ai.Options
|
||||
}
|
||||
|
||||
// NewProvider creates a new Atlas Cloud provider.
|
||||
func NewProvider(opts ...ai.Option) *Provider {
|
||||
options := ai.NewOptions(opts...)
|
||||
|
||||
if options.Model == "" {
|
||||
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},
|
||||
{"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,
|
||||
})
|
||||
}
|
||||
|
||||
followUpReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": followUpMessages,
|
||||
}
|
||||
|
||||
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("streaming not yet implemented for atlascloud provider")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package atlascloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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_NotImplemented(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
req := &ai.Request{
|
||||
Prompt: "Hello",
|
||||
}
|
||||
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Error("Expected error for unimplemented streaming, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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("streaming not yet implemented for gemini provider")
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 err == nil {
|
||||
t.Error("Expected error for unimplemented streaming, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
-194
@@ -1,194 +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"
|
||||
)
|
||||
|
||||
func init() {
|
||||
ai.Register("groq", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
}
|
||||
|
||||
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 nil, fmt.Errorf("streaming not yet implemented for groq provider")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package groq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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_NotImplemented(t *testing.T) {
|
||||
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
|
||||
t.Error("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
// Package mistral implements the Mistral AI model provider.
|
||||
//
|
||||
// Mistral AI is a European AI company offering high-performance models
|
||||
// via an OpenAI-compatible chat completions endpoint.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// import _ "go-micro.dev/v6/ai/mistral"
|
||||
//
|
||||
// m := ai.New("mistral",
|
||||
// ai.WithAPIKey("your-api-key"),
|
||||
// )
|
||||
package mistral
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
func init() {
|
||||
ai.Register("mistral", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
opts ai.Options
|
||||
}
|
||||
|
||||
func NewProvider(opts ...ai.Option) *Provider {
|
||||
options := ai.NewOptions(opts...)
|
||||
if options.Model == "" {
|
||||
options.Model = "mistral-large-latest"
|
||||
}
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "https://api.mistral.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 "mistral" }
|
||||
|
||||
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 nil, fmt.Errorf("streaming not yet implemented for mistral provider")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package mistral
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
func TestProvider_String(t *testing.T) {
|
||||
if NewProvider().String() != "mistral" {
|
||||
t.Errorf("got %q", NewProvider().String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Defaults(t *testing.T) {
|
||||
opts := NewProvider().Options()
|
||||
if opts.Model != "mistral-large-latest" {
|
||||
t.Errorf("default model = %q", opts.Model)
|
||||
}
|
||||
if opts.BaseURL != "https://api.mistral.ai" {
|
||||
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_NotImplemented(t *testing.T) {
|
||||
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
|
||||
t.Error("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Registration(t *testing.T) {
|
||||
m := ai.New("mistral", ai.WithAPIKey("test"))
|
||||
if m == nil {
|
||||
t.Fatal("provider not registered")
|
||||
}
|
||||
if m.String() != "mistral" {
|
||||
t.Errorf("got %q", m.String())
|
||||
}
|
||||
}
|
||||
-214
@@ -1,214 +0,0 @@
|
||||
// Package ai provides abstraction for AI model providers
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Model provides an interface for interacting with AI model providers
|
||||
type Model interface {
|
||||
// Init initializes the model with options
|
||||
Init(...Option) error
|
||||
// Options returns the model options
|
||||
Options() Options
|
||||
// Generate generates a response from the model
|
||||
Generate(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error)
|
||||
// Stream generates a streaming response (for future implementation)
|
||||
Stream(ctx context.Context, req *Request, opts ...GenerateOption) (Stream, error)
|
||||
// String returns the name of the provider
|
||||
String() string
|
||||
}
|
||||
|
||||
// Tool represents a tool/function that can be called by the model
|
||||
type Tool struct {
|
||||
Name string // LLM-safe name (e.g., "greeter_Greeter_Hello")
|
||||
OriginalName string // Original name (e.g., "greeter.Greeter.Hello")
|
||||
Description string
|
||||
Properties map[string]any // JSON schema for tool parameters
|
||||
}
|
||||
|
||||
// Request represents a request to generate content from a model
|
||||
type Request struct {
|
||||
// Prompt is the user's message/prompt
|
||||
Prompt string
|
||||
// SystemPrompt is the system instruction for the model
|
||||
SystemPrompt string
|
||||
// Tools available for the model to use
|
||||
Tools []Tool
|
||||
// Messages for continuing a conversation (optional).
|
||||
// Use ai.History to accumulate these across turns.
|
||||
Messages []Message
|
||||
}
|
||||
|
||||
// Message represents a conversation message
|
||||
type Message struct {
|
||||
Role string // "user", "assistant", "system", "tool"
|
||||
Content any // Can be string or structured content
|
||||
}
|
||||
|
||||
// Usage describes token counts returned by model providers.
|
||||
type Usage struct {
|
||||
InputTokens int `json:"input_tokens,omitempty"`
|
||||
OutputTokens int `json:"output_tokens,omitempty"`
|
||||
TotalTokens int `json:"total_tokens,omitempty"`
|
||||
}
|
||||
|
||||
// Response represents the response from a model
|
||||
type Response struct {
|
||||
// Reply is the text response from the model
|
||||
Reply string
|
||||
// ToolCalls are tool calls requested by the model
|
||||
ToolCalls []ToolCall
|
||||
// Answer is the final answer after tool execution (if tools were used)
|
||||
Answer string
|
||||
// Usage contains provider token usage when available.
|
||||
Usage Usage
|
||||
}
|
||||
|
||||
// ToolCall represents a request to call a tool and its result
|
||||
type ToolCall struct {
|
||||
ID string // Tool call ID (for correlation)
|
||||
Name string // Tool name
|
||||
Input map[string]any // Tool input arguments
|
||||
Result string // Tool execution result (populated after execution)
|
||||
Error string // Tool execution error (populated after execution)
|
||||
}
|
||||
|
||||
// Scan decodes the call's Input into v (a pointer to a struct or map),
|
||||
// the same way a codec decodes an RPC request body. Use it when a tool
|
||||
// wants typed arguments instead of the raw map:
|
||||
//
|
||||
// var args struct{ Query string `json:"query"` }
|
||||
// if err := call.Scan(&args); err != nil { ... }
|
||||
func (c ToolCall) Scan(v any) error {
|
||||
b, err := json.Marshal(c.Input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(b, v)
|
||||
}
|
||||
|
||||
// ToolResult represents the result of a tool execution
|
||||
type ToolResult struct {
|
||||
ID string // Tool call ID (for correlation)
|
||||
Value any // Structured result (optional)
|
||||
Content string // Tool execution result (JSON string), shown to the model
|
||||
// Refused names the reason a guardrail blocked the call before it ran
|
||||
// ("max_steps", "loop", "approval"); empty when the call executed. A
|
||||
// tool wrapper can switch on it to build reliability tooling — react to
|
||||
// a detected loop, audit refusals — without parsing the message.
|
||||
Refused string `json:"refused,omitempty"`
|
||||
}
|
||||
|
||||
// Refusal reason codes set on ToolResult.Refused by the agent's guardrails.
|
||||
const (
|
||||
RefusedMaxSteps = "max_steps"
|
||||
RefusedLoop = "loop"
|
||||
RefusedApproval = "approval"
|
||||
)
|
||||
|
||||
// RunInfo describes the agent run a tool call belongs to. The agent
|
||||
// attaches it to the context passed to a ToolHandler, so a wrapper can
|
||||
// correlate calls within a run and across delegation without coupling to
|
||||
// the agent package. Per-call detail (tool name, id) is on the ToolCall;
|
||||
// step and attempt counts are naturally counted by the wrapper itself.
|
||||
type RunInfo struct {
|
||||
RunID string // correlation id for this agent run (one per Ask)
|
||||
ParentID string // the run that delegated to this one, if any
|
||||
Agent string // the agent's name
|
||||
}
|
||||
|
||||
type runInfoKey struct{}
|
||||
|
||||
// WithRunInfo attaches run info to ctx.
|
||||
func WithRunInfo(ctx context.Context, r RunInfo) context.Context {
|
||||
return context.WithValue(ctx, runInfoKey{}, r)
|
||||
}
|
||||
|
||||
// RunInfoFrom returns the run info attached to ctx, and whether it was set.
|
||||
func RunInfoFrom(ctx context.Context) (RunInfo, bool) {
|
||||
r, ok := ctx.Value(runInfoKey{}).(RunInfo)
|
||||
return r, ok
|
||||
}
|
||||
|
||||
// Stream is the interface for streaming responses (future implementation)
|
||||
type Stream interface {
|
||||
// Recv receives the next chunk of the response
|
||||
Recv() (*Response, error)
|
||||
// Close closes the stream
|
||||
Close() error
|
||||
}
|
||||
|
||||
// ToolHandler executes a tool call and returns its result. It mirrors a
|
||||
// go-micro RPC handler — context first, a request in, a result out — so
|
||||
// the same mental model carries over from services to tools.
|
||||
type ToolHandler func(ctx context.Context, call ToolCall) ToolResult
|
||||
|
||||
// ToolWrapper wraps a ToolHandler to add behavior around execution —
|
||||
// logging, metrics, retries, guardrails. It is the tool-side analog of
|
||||
// client.CallWrapper and server.HandlerWrapper: a wrapper takes the next
|
||||
// handler and returns a new one, and code before the next(...) call runs
|
||||
// before the tool, code after runs after.
|
||||
type ToolWrapper func(ToolHandler) ToolHandler
|
||||
|
||||
// NewFunc creates a new Model instance
|
||||
type NewFunc func(...Option) Model
|
||||
|
||||
var providers = make(map[string]NewFunc)
|
||||
|
||||
// Register registers a model provider
|
||||
func Register(name string, fn NewFunc) {
|
||||
providers[name] = fn
|
||||
}
|
||||
|
||||
// New creates a new Model instance based on the provider name
|
||||
func New(provider string, opts ...Option) Model {
|
||||
if fn, ok := providers[provider]; ok {
|
||||
return fn(opts...)
|
||||
}
|
||||
|
||||
// Default to first registered provider
|
||||
if len(providers) > 0 {
|
||||
for _, fn := range providers {
|
||||
return fn(opts...)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoDetectProvider attempts to detect the provider from the base URL
|
||||
func AutoDetectProvider(baseURL string) string {
|
||||
if baseURL == "" {
|
||||
return "openai"
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(baseURL, "anthropic"):
|
||||
return "anthropic"
|
||||
case strings.Contains(baseURL, "atlascloud"):
|
||||
return "atlascloud"
|
||||
case strings.Contains(baseURL, "googleapis.com"), strings.Contains(baseURL, "google"):
|
||||
return "gemini"
|
||||
case strings.Contains(baseURL, "groq"):
|
||||
return "groq"
|
||||
case strings.Contains(baseURL, "mistral"):
|
||||
return "mistral"
|
||||
case strings.Contains(baseURL, "together"):
|
||||
return "together"
|
||||
default:
|
||||
return "openai"
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultModel is a default model instance
|
||||
var DefaultModel Model
|
||||
|
||||
// Generate generates a response using the default model.
|
||||
func Generate(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error) {
|
||||
if DefaultModel == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return DefaultModel.Generate(ctx, req, opts...)
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
// Package openai implements the OpenAI model provider
|
||||
package openai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
func init() {
|
||||
ai.Register("openai", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
ai.RegisterImage("openai", func(opts ...ai.Option) ai.ImageModel {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
}
|
||||
|
||||
// Provider implements the ai.Model interface for OpenAI
|
||||
type Provider struct {
|
||||
opts ai.Options
|
||||
}
|
||||
|
||||
// NewProvider creates a new OpenAI provider
|
||||
func NewProvider(opts ...ai.Option) *Provider {
|
||||
options := ai.NewOptions(opts...)
|
||||
|
||||
// Set defaults if not provided
|
||||
if options.Model == "" {
|
||||
options.Model = "gpt-4o"
|
||||
}
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "https://api.openai.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 "openai"
|
||||
}
|
||||
|
||||
// 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 OpenAI format
|
||||
var openaiTools []map[string]any
|
||||
for _, t := range req.Tools {
|
||||
openaiTools = append(openaiTools, map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": t.Name,
|
||||
"description": t.Description,
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": t.Properties,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Build messages
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": req.SystemPrompt},
|
||||
{"role": "user", "content": req.Prompt},
|
||||
}
|
||||
|
||||
// Build initial request
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
if len(openaiTools) > 0 {
|
||||
apiReq["tools"] = openaiTools
|
||||
}
|
||||
|
||||
// Make API call
|
||||
resp, rawMessage, err := p.callAPI(ctx, apiReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If no tool calls, return response
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// If tool handler is provided, execute tools and get final answer
|
||||
if p.opts.ToolHandler != nil {
|
||||
// Build follow-up messages
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
followUpReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": followUpMessages,
|
||||
}
|
||||
|
||||
// Make follow-up API call
|
||||
followUpResp, _, err := p.callAPI(ctx, followUpReq)
|
||||
if err == nil && followUpResp.Reply != "" {
|
||||
resp.Answer = followUpResp.Reply
|
||||
}
|
||||
}
|
||||
|
||||
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("streaming not yet implemented for openai provider")
|
||||
}
|
||||
|
||||
// callAPI makes an HTTP request to the OpenAI API
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]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/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)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
|
||||
|
||||
// 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 chatResp struct {
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
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,
|
||||
Usage: ai.Usage{InputTokens: chatResp.Usage.PromptTokens, OutputTokens: chatResp.Usage.CompletionTokens, TotalTokens: chatResp.Usage.TotalTokens},
|
||||
}
|
||||
|
||||
// Extract tool calls
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// Return raw message for potential follow-up
|
||||
rawMessage := map[string]any{
|
||||
"content": choice.Message.Content,
|
||||
"tool_calls": choice.Message.ToolCalls,
|
||||
}
|
||||
|
||||
return response, rawMessage, nil
|
||||
}
|
||||
|
||||
const defaultImageModel = "gpt-image-1"
|
||||
|
||||
func (p *Provider) GenerateImage(ctx context.Context, req *ai.ImageRequest, opts ...ai.GenerateOption) (*ai.ImageResponse, error) {
|
||||
model := req.Model
|
||||
if model == "" {
|
||||
model = defaultImageModel
|
||||
}
|
||||
n := req.N
|
||||
if n <= 0 {
|
||||
n = 1
|
||||
}
|
||||
|
||||
apiReq := map[string]any{
|
||||
"model": model,
|
||||
"prompt": req.Prompt,
|
||||
"n": n,
|
||||
}
|
||||
if req.Size != "" {
|
||||
apiReq["size"] = req.Size
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(apiReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/images/generations"
|
||||
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 imgResp struct {
|
||||
Data []struct {
|
||||
URL string `json:"url"`
|
||||
B64JSON string `json:"b64_json"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, &imgResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
response := &ai.ImageResponse{}
|
||||
for _, d := range imgResp.Data {
|
||||
response.Images = append(response.Images, ai.Image{
|
||||
URL: d.URL,
|
||||
Base64: d.B64JSON,
|
||||
})
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
func TestProvider_String(t *testing.T) {
|
||||
p := NewProvider()
|
||||
if p.String() != "openai" {
|
||||
t.Errorf("Expected provider name 'openai', 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 != "gpt-4o" {
|
||||
t.Errorf("Expected default model 'gpt-4o', got '%s'", opts.Model)
|
||||
}
|
||||
if opts.BaseURL != "https://api.openai.com" {
|
||||
t.Errorf("Expected default base URL 'https://api.openai.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 err == nil {
|
||||
t.Error("Expected error for unimplemented streaming, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_ImageRegistration(t *testing.T) {
|
||||
ig := ai.NewImage("openai", ai.WithAPIKey("test"))
|
||||
if ig == nil {
|
||||
t.Fatal("ai.NewImage('openai') returned nil — image provider not registered")
|
||||
}
|
||||
if ig.String() != "openai" {
|
||||
t.Errorf("Expected 'openai', 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)
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Options for model configuration
|
||||
type Options struct {
|
||||
// Context for the model
|
||||
Context context.Context
|
||||
// Model name (e.g., "gpt-4o", "claude-sonnet-4-20250514")
|
||||
Model string
|
||||
// APIKey for authentication
|
||||
APIKey string
|
||||
// BaseURL for the API endpoint
|
||||
BaseURL string
|
||||
// ToolHandler handles tool calls (optional, for automatic tool execution)
|
||||
ToolHandler ToolHandler
|
||||
}
|
||||
|
||||
// GenerateOptions for generate call
|
||||
type GenerateOptions struct {
|
||||
// Context for this specific generate call
|
||||
Context context.Context
|
||||
}
|
||||
|
||||
// Option is a function that modifies Options
|
||||
type Option func(*Options)
|
||||
|
||||
// GenerateOption is a function that modifies GenerateOptions
|
||||
type GenerateOption func(*GenerateOptions)
|
||||
|
||||
// NewOptions creates new Options with defaults
|
||||
func NewOptions(opts ...Option) Options {
|
||||
options := Options{
|
||||
Context: context.Background(),
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// WithModel sets the model name
|
||||
func WithModel(m string) Option {
|
||||
return func(o *Options) {
|
||||
o.Model = m
|
||||
}
|
||||
}
|
||||
|
||||
// WithAPIKey sets the API key
|
||||
func WithAPIKey(key string) Option {
|
||||
return func(o *Options) {
|
||||
o.APIKey = key
|
||||
}
|
||||
}
|
||||
|
||||
// WithBaseURL sets the base URL
|
||||
func WithBaseURL(url string) Option {
|
||||
return func(o *Options) {
|
||||
o.BaseURL = url
|
||||
}
|
||||
}
|
||||
|
||||
// WithContext sets the context
|
||||
func WithContext(ctx context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = ctx
|
||||
}
|
||||
}
|
||||
|
||||
// WithToolHandler sets the tool handler
|
||||
func WithToolHandler(handler ToolHandler) Option {
|
||||
return func(o *Options) {
|
||||
o.ToolHandler = handler
|
||||
}
|
||||
}
|
||||
|
||||
// WithTools wires a Tools instance into the model, setting the tool
|
||||
// handler so the model can execute discovered service endpoints. The
|
||||
// tool list itself is passed per-request via Request.Tools.
|
||||
//
|
||||
// tools := ai.NewTools(service.Registry())
|
||||
// list, _ := tools.Discover()
|
||||
// m := ai.New("anthropic", ai.WithAPIKey(key), ai.WithTools(tools))
|
||||
// resp, _ := m.Generate(ctx, &ai.Request{Prompt: input, Tools: list})
|
||||
func WithTools(t *Tools) Option {
|
||||
return func(o *Options) {
|
||||
if t != nil {
|
||||
o.ToolHandler = t.Handler()
|
||||
}
|
||||
}
|
||||
}
|
||||
-125
@@ -1,125 +0,0 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StatusCoder is implemented by provider errors that expose an HTTP-like status code.
|
||||
type StatusCoder interface {
|
||||
StatusCode() int
|
||||
}
|
||||
|
||||
// RetryError is returned when Generate is retried and still fails.
|
||||
type RetryError struct {
|
||||
Attempts int
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *RetryError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("ai generate failed after %d attempt(s): %v", e.Attempts, e.Err)
|
||||
}
|
||||
|
||||
func (e *RetryError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// GeneratePolicy controls timeout and retry behavior for a model call.
|
||||
type GeneratePolicy struct {
|
||||
Timeout time.Duration
|
||||
MaxAttempts int
|
||||
Backoff time.Duration
|
||||
}
|
||||
|
||||
// GenerateWithRetry calls m.Generate with per-attempt timeout and bounded retry.
|
||||
func GenerateWithRetry(ctx context.Context, m Model, req *Request, policy GeneratePolicy, opts ...GenerateOption) (*Response, error) {
|
||||
if policy.MaxAttempts <= 0 {
|
||||
policy.MaxAttempts = 1
|
||||
}
|
||||
if m == nil {
|
||||
return nil, errors.New("ai model is nil")
|
||||
}
|
||||
|
||||
var last error
|
||||
for attempt := 1; attempt <= policy.MaxAttempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
callCtx := ctx
|
||||
cancel := func() {}
|
||||
if policy.Timeout > 0 {
|
||||
callCtx, cancel = context.WithTimeout(ctx, policy.Timeout)
|
||||
}
|
||||
resp, err := m.Generate(callCtx, req, opts...)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
last = err
|
||||
|
||||
// Caller cancellation/deadline always wins and is not retried.
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if attempt == policy.MaxAttempts || !IsTransientError(err) {
|
||||
if attempt > 1 || IsTransientError(err) {
|
||||
return nil, &RetryError{Attempts: attempt, Err: err}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Always back off between retries — exponential and capped — so an
|
||||
// opt-in retry can never become a tight loop hammering the provider,
|
||||
// even if Backoff was left at zero.
|
||||
backoff := policy.Backoff
|
||||
if backoff <= 0 {
|
||||
backoff = 200 * time.Millisecond
|
||||
}
|
||||
if shift := attempt - 1; shift > 0 {
|
||||
backoff <<= shift
|
||||
}
|
||||
if backoff > 30*time.Second {
|
||||
backoff = 30 * time.Second
|
||||
}
|
||||
t := time.NewTimer(backoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if !t.Stop() {
|
||||
<-t.C
|
||||
}
|
||||
return nil, ctx.Err()
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
return nil, &RetryError{Attempts: policy.MaxAttempts, Err: last}
|
||||
}
|
||||
|
||||
// IsTransientError reports whether err is worth retrying at the provider boundary.
|
||||
func IsTransientError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
var sc StatusCoder
|
||||
if errors.As(err, &sc) {
|
||||
code := sc.StatusCode()
|
||||
return code == 429 || code >= 500
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "rate limit") || strings.Contains(msg, "too many requests") || strings.Contains(msg, "timeout") || strings.Contains(msg, "temporar")
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
// Package together implements the Together AI model provider.
|
||||
//
|
||||
// Together AI provides fast inference for open-weight models via an
|
||||
// OpenAI-compatible chat completions endpoint.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// import _ "go-micro.dev/v6/ai/together"
|
||||
//
|
||||
// m := ai.New("together",
|
||||
// ai.WithAPIKey("your-api-key"),
|
||||
// )
|
||||
package together
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
func init() {
|
||||
ai.Register("together", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
opts ai.Options
|
||||
}
|
||||
|
||||
func NewProvider(opts ...ai.Option) *Provider {
|
||||
options := ai.NewOptions(opts...)
|
||||
if options.Model == "" {
|
||||
options.Model = "meta-llama/Llama-3.3-70B-Instruct-Turbo"
|
||||
}
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "https://api.together.xyz"
|
||||
}
|
||||
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 "together" }
|
||||
|
||||
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 nil, fmt.Errorf("streaming not yet implemented for together provider")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package together
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
func TestProvider_String(t *testing.T) {
|
||||
if NewProvider().String() != "together" {
|
||||
t.Errorf("got %q", NewProvider().String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Defaults(t *testing.T) {
|
||||
opts := NewProvider().Options()
|
||||
if opts.Model != "meta-llama/Llama-3.3-70B-Instruct-Turbo" {
|
||||
t.Errorf("default model = %q", opts.Model)
|
||||
}
|
||||
if opts.BaseURL != "https://api.together.xyz" {
|
||||
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_NotImplemented(t *testing.T) {
|
||||
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
|
||||
t.Error("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Registration(t *testing.T) {
|
||||
m := ai.New("together", ai.WithAPIKey("test"))
|
||||
if m == nil {
|
||||
t.Fatal("provider not registered")
|
||||
}
|
||||
if m.String() != "together" {
|
||||
t.Errorf("got %q", m.String())
|
||||
}
|
||||
}
|
||||
-185
@@ -1,185 +0,0 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"go-micro.dev/v6/client"
|
||||
codecBytes "go-micro.dev/v6/codec/bytes"
|
||||
"go-micro.dev/v6/registry"
|
||||
)
|
||||
|
||||
type toolNameMap struct {
|
||||
mu sync.RWMutex
|
||||
m map[string]string
|
||||
}
|
||||
|
||||
func (n *toolNameMap) put(safe, original string) {
|
||||
n.mu.Lock()
|
||||
n.m[safe] = original
|
||||
n.mu.Unlock()
|
||||
}
|
||||
|
||||
func (n *toolNameMap) get(safe string) (string, bool) {
|
||||
n.mu.RLock()
|
||||
v, ok := n.m[safe]
|
||||
n.mu.RUnlock()
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// Tools discovers go-micro services from a registry and converts their
|
||||
// endpoints into Tool definitions. It also executes tool calls via RPC.
|
||||
//
|
||||
// Create with NewTools, discover the tool list with Discover, and wire
|
||||
// execution into a model with WithTools:
|
||||
//
|
||||
// tools := ai.NewTools(service.Registry())
|
||||
// list, _ := tools.Discover()
|
||||
// m := ai.New("anthropic", ai.WithAPIKey(key), ai.WithTools(tools))
|
||||
// resp, _ := m.Generate(ctx, &ai.Request{Prompt: input, Tools: list})
|
||||
type Tools struct {
|
||||
registry registry.Registry
|
||||
client client.Client
|
||||
names *toolNameMap
|
||||
}
|
||||
|
||||
// ToolOption configures a Tools instance.
|
||||
type ToolOption func(*Tools)
|
||||
|
||||
// ToolClient sets the client used to execute tool calls. Defaults to
|
||||
// client.DefaultClient.
|
||||
func ToolClient(c client.Client) ToolOption {
|
||||
return func(t *Tools) {
|
||||
if c != nil {
|
||||
t.client = c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewTools creates a Tools bound to the given registry.
|
||||
func NewTools(reg registry.Registry, opts ...ToolOption) *Tools {
|
||||
t := &Tools{
|
||||
registry: reg,
|
||||
client: client.DefaultClient,
|
||||
names: &toolNameMap{m: map[string]string{}},
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(t)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Discover walks the registry and returns one Tool per service
|
||||
// endpoint. Tool names are LLM-safe (dots replaced with underscores).
|
||||
func (t *Tools) Discover() ([]Tool, error) {
|
||||
services, err := t.registry.ListServices()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []Tool
|
||||
for _, svc := range services {
|
||||
full, err := t.registry.GetService(svc.Name)
|
||||
if err != nil || len(full) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, ep := range full[0].Endpoints {
|
||||
original := fmt.Sprintf("%s.%s", svc.Name, ep.Name)
|
||||
safe := strings.ReplaceAll(original, ".", "_")
|
||||
t.names.put(safe, original)
|
||||
|
||||
desc := fmt.Sprintf("Call %s on %s service", ep.Name, svc.Name)
|
||||
if ep.Metadata != nil {
|
||||
if d, ok := ep.Metadata["description"]; ok && d != "" {
|
||||
desc = d
|
||||
}
|
||||
}
|
||||
|
||||
props := map[string]any{}
|
||||
if ep.Request != nil {
|
||||
for _, field := range ep.Request.Values {
|
||||
props[field.Name] = map[string]any{
|
||||
"type": toolJSONType(field.Type),
|
||||
"description": fmt.Sprintf("%s (%s)", field.Name, field.Type),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out = append(out, Tool{
|
||||
Name: safe,
|
||||
OriginalName: original,
|
||||
Description: desc,
|
||||
Properties: props,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Handler returns a ToolHandler that executes tool calls via RPC using
|
||||
// the configured client. Tool names may be LLM-safe (underscored) or
|
||||
// original (dotted). WithTools uses this internally.
|
||||
func (t *Tools) Handler() ToolHandler {
|
||||
c := t.client
|
||||
if c == nil {
|
||||
c = client.DefaultClient
|
||||
}
|
||||
return func(ctx context.Context, call ToolCall) ToolResult {
|
||||
name := call.Name
|
||||
if orig, ok := t.names.get(name); ok {
|
||||
name = orig
|
||||
}
|
||||
parts := strings.SplitN(name, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return toolErrResult(call.ID, "invalid tool name: "+name)
|
||||
}
|
||||
|
||||
inputBytes, err := json.Marshal(call.Input)
|
||||
if err != nil {
|
||||
return toolErrResult(call.ID, "failed to marshal input: "+err.Error())
|
||||
}
|
||||
|
||||
req := c.NewRequest(parts[0], parts[1], &codecBytes.Frame{Data: inputBytes})
|
||||
var rsp codecBytes.Frame
|
||||
if err := c.Call(ctx, req, &rsp); err != nil {
|
||||
return toolErrResult(call.ID, err.Error())
|
||||
}
|
||||
|
||||
var result any
|
||||
if err := json.Unmarshal(rsp.Data, &result); err != nil {
|
||||
result = string(rsp.Data)
|
||||
}
|
||||
return ToolResult{ID: call.ID, Value: result, Content: string(rsp.Data)}
|
||||
}
|
||||
}
|
||||
|
||||
// DiscoverTools is a convenience that discovers tools from a registry
|
||||
// without creating a Tools instance. For paired discovery + execution,
|
||||
// create a Tools with NewTools instead.
|
||||
func DiscoverTools(reg registry.Registry) ([]Tool, error) {
|
||||
return NewTools(reg).Discover()
|
||||
}
|
||||
|
||||
func toolErrResult(id, msg string) ToolResult {
|
||||
encoded, _ := json.Marshal(map[string]string{"error": msg})
|
||||
return ToolResult{ID: id, Value: map[string]string{"error": msg}, Content: string(encoded)}
|
||||
}
|
||||
|
||||
func toolJSONType(goType string) string {
|
||||
switch goType {
|
||||
case "string":
|
||||
return "string"
|
||||
case "int", "int32", "int64", "uint", "uint32", "uint64":
|
||||
return "integer"
|
||||
case "float32", "float64":
|
||||
return "number"
|
||||
case "bool":
|
||||
return "boolean"
|
||||
default:
|
||||
return "object"
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/registry"
|
||||
)
|
||||
|
||||
func TestToolJSONType(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"string": "string",
|
||||
"int": "integer",
|
||||
"int64": "integer",
|
||||
"float64": "number",
|
||||
"bool": "boolean",
|
||||
"User": "object",
|
||||
"": "object",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := toolJSONType(in); got != want {
|
||||
t.Errorf("toolJSONType(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverTools_Empty(t *testing.T) {
|
||||
reg := registry.NewMemoryRegistry()
|
||||
tools, err := DiscoverTools(reg)
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverTools: %v", err)
|
||||
}
|
||||
if len(tools) != 0 {
|
||||
t.Errorf("expected 0 tools, got %d", len(tools))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverTools_DiscoversEndpoints(t *testing.T) {
|
||||
reg := registry.NewMemoryRegistry()
|
||||
svc := ®istry.Service{
|
||||
Name: "users",
|
||||
Version: "1.0.0",
|
||||
Nodes: []*registry.Node{
|
||||
{Id: "users-1", Address: "127.0.0.1:9000"},
|
||||
},
|
||||
Endpoints: []*registry.Endpoint{
|
||||
{
|
||||
Name: "Users.Get",
|
||||
Metadata: map[string]string{
|
||||
"description": "Fetch a user by ID",
|
||||
},
|
||||
Request: ®istry.Value{
|
||||
Name: "GetRequest",
|
||||
Type: "GetRequest",
|
||||
Values: []*registry.Value{
|
||||
{Name: "id", Type: "string"},
|
||||
{Name: "expand", Type: "bool"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := reg.Register(svc); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
|
||||
tools, err := DiscoverTools(reg)
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverTools: %v", err)
|
||||
}
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("expected 1 tool, got %d", len(tools))
|
||||
}
|
||||
|
||||
tool := tools[0]
|
||||
if tool.Name != "users_Users_Get" {
|
||||
t.Errorf("safe name = %q", tool.Name)
|
||||
}
|
||||
if tool.OriginalName != "users.Users.Get" {
|
||||
t.Errorf("original = %q", tool.OriginalName)
|
||||
}
|
||||
if tool.Description != "Fetch a user by ID" {
|
||||
t.Errorf("description = %q", tool.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTools_HandlerResolvesSafeName(t *testing.T) {
|
||||
tools := NewTools(registry.NewMemoryRegistry())
|
||||
tools.names.put("users_Users_Get", "users.Users.Get")
|
||||
|
||||
resolved, ok := tools.names.get("users_Users_Get")
|
||||
if !ok || resolved != "users.Users.Get" {
|
||||
t.Errorf("name map lookup = (%q, %v)", resolved, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTools_HandlerInvalidName(t *testing.T) {
|
||||
tools := NewTools(registry.NewMemoryRegistry())
|
||||
h := tools.Handler()
|
||||
|
||||
res := h(context.Background(), ToolCall{Name: "foo", Input: map[string]any{}})
|
||||
if res.Value == nil {
|
||||
t.Fatal("expected error result")
|
||||
}
|
||||
if res.Content == "" {
|
||||
t.Error("expected non-empty content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithTools(t *testing.T) {
|
||||
tools := NewTools(registry.NewMemoryRegistry())
|
||||
opts := NewOptions(WithTools(tools))
|
||||
if opts.ToolHandler == nil {
|
||||
t.Error("WithTools did not set a ToolHandler")
|
||||
}
|
||||
}
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
package ai
|
||||
|
||||
import "context"
|
||||
|
||||
// VideoModel provides an interface for video generation providers.
|
||||
// Providers that support video generation implement this alongside
|
||||
// Model and/or ImageModel.
|
||||
type VideoModel interface {
|
||||
GenerateVideo(ctx context.Context, req *VideoRequest, opts ...GenerateOption) (*VideoResponse, error)
|
||||
String() string
|
||||
}
|
||||
|
||||
// VideoRequest describes what video to generate.
|
||||
type VideoRequest struct {
|
||||
// Prompt is the text description or instructions for the video.
|
||||
Prompt string
|
||||
// Model overrides the provider's default video model.
|
||||
Model string
|
||||
// Images are reference image URLs for image-to-video generation.
|
||||
Images []string
|
||||
// Duration in seconds. Provider-specific defaults apply.
|
||||
Duration int
|
||||
// AspectRatio (e.g. "16:9", "9:16"). Provider-specific.
|
||||
AspectRatio string
|
||||
// Resolution (e.g. "720p", "1080p"). Provider-specific.
|
||||
Resolution string
|
||||
}
|
||||
|
||||
// VideoResponse holds the generated video.
|
||||
type VideoResponse struct {
|
||||
// URL is the remote URL where the video can be fetched.
|
||||
URL string
|
||||
}
|
||||
|
||||
// NewVideoFunc creates a new VideoModel instance.
|
||||
type NewVideoFunc func(...Option) VideoModel
|
||||
|
||||
var videoProviders = make(map[string]NewVideoFunc)
|
||||
|
||||
// RegisterVideo registers a video generation provider.
|
||||
func RegisterVideo(name string, fn NewVideoFunc) {
|
||||
videoProviders[name] = fn
|
||||
}
|
||||
|
||||
// NewVideo creates a new VideoModel instance based on the provider name.
|
||||
func NewVideo(provider string, opts ...Option) VideoModel {
|
||||
if fn, ok := videoProviders[provider]; ok {
|
||||
return fn(opts...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/micro/go-micro/registry"
|
||||
"github.com/micro/go-micro/server"
|
||||
)
|
||||
|
||||
// Endpoint is a mapping between an RPC method and HTTP endpoint
|
||||
type Endpoint struct {
|
||||
// RPC Method e.g. Greeter.Hello
|
||||
Name string
|
||||
// Description e.g what's this endpoint for
|
||||
Description string
|
||||
// API Handler e.g rpc, proxy
|
||||
Handler string
|
||||
// HTTP Host e.g example.com
|
||||
Host []string
|
||||
// HTTP Methods e.g GET, POST
|
||||
Method []string
|
||||
// HTTP Path e.g /greeter. Expect POSIX regex
|
||||
Path []string
|
||||
}
|
||||
|
||||
// Service represents an API service
|
||||
type Service struct {
|
||||
// Name of service
|
||||
Name string
|
||||
// The endpoint for this service
|
||||
Endpoint *Endpoint
|
||||
// Versions of this service
|
||||
Services []*registry.Service
|
||||
}
|
||||
|
||||
func strip(s string) string {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func slice(s string) []string {
|
||||
var sl []string
|
||||
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
if str := strip(p); len(str) > 0 {
|
||||
sl = append(sl, strip(p))
|
||||
}
|
||||
}
|
||||
|
||||
return sl
|
||||
}
|
||||
|
||||
// Encode encodes an endpoint to endpoint metadata
|
||||
func Encode(e *Endpoint) map[string]string {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"endpoint": e.Name,
|
||||
"description": e.Description,
|
||||
"method": strings.Join(e.Method, ","),
|
||||
"path": strings.Join(e.Path, ","),
|
||||
"host": strings.Join(e.Host, ","),
|
||||
"handler": e.Handler,
|
||||
}
|
||||
}
|
||||
|
||||
// Decode decodes endpoint metadata into an endpoint
|
||||
func Decode(e map[string]string) *Endpoint {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &Endpoint{
|
||||
Name: e["endpoint"],
|
||||
Description: e["description"],
|
||||
Method: slice(e["method"]),
|
||||
Path: slice(e["path"]),
|
||||
Host: slice(e["host"]),
|
||||
Handler: e["handler"],
|
||||
}
|
||||
}
|
||||
|
||||
// Validate validates an endpoint to guarantee it won't blow up when being served
|
||||
func Validate(e *Endpoint) error {
|
||||
if e == nil {
|
||||
return errors.New("endpoint is nil")
|
||||
}
|
||||
|
||||
if len(e.Name) == 0 {
|
||||
return errors.New("name required")
|
||||
}
|
||||
|
||||
for _, p := range e.Path {
|
||||
_, err := regexp.CompilePOSIX(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(e.Handler) == 0 {
|
||||
return errors.New("invalid handler")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/*
|
||||
Design ideas
|
||||
|
||||
// Gateway is an api gateway interface
|
||||
type Gateway interface {
|
||||
// Register a http handler
|
||||
Handle(pattern string, http.Handler)
|
||||
// Register a route
|
||||
RegisterRoute(r Route)
|
||||
// Init initialises the command line.
|
||||
// It also parses further options.
|
||||
Init(...Option) error
|
||||
// Run the gateway
|
||||
Run() error
|
||||
}
|
||||
|
||||
// NewGateway returns a new api gateway
|
||||
func NewGateway() Gateway {
|
||||
return newGateway()
|
||||
}
|
||||
*/
|
||||
|
||||
// WithEndpoint returns a server.HandlerOption with endpoint metadata set
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// proto.RegisterHandler(service.Server(), new(Handler), api.WithEndpoint(
|
||||
// &api.Endpoint{
|
||||
// Name: "Greeter.Hello",
|
||||
// Path: []string{"/greeter"},
|
||||
// },
|
||||
// ))
|
||||
func WithEndpoint(e *Endpoint) server.HandlerOption {
|
||||
return server.EndpointMetadata(e.Name, Encode(e))
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncoding(t *testing.T) {
|
||||
testData := []*Endpoint{
|
||||
nil,
|
||||
{
|
||||
Name: "Foo.Bar",
|
||||
Description: "A test endpoint",
|
||||
Handler: "meta",
|
||||
Host: []string{"foo.com"},
|
||||
Method: []string{"GET"},
|
||||
Path: []string{"/test"},
|
||||
},
|
||||
}
|
||||
|
||||
compare := func(expect, got []string) bool {
|
||||
// no data to compare, return true
|
||||
if len(expect) == 0 && len(got) == 0 {
|
||||
return true
|
||||
}
|
||||
// no data expected but got some return false
|
||||
if len(expect) == 0 && len(got) > 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// compare expected with what we got
|
||||
for _, e := range expect {
|
||||
var seen bool
|
||||
for _, g := range got {
|
||||
if e == g {
|
||||
seen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !seen {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// we're done, return true
|
||||
return true
|
||||
}
|
||||
|
||||
for _, d := range testData {
|
||||
// encode
|
||||
e := Encode(d)
|
||||
// decode
|
||||
de := Decode(e)
|
||||
|
||||
// nil endpoint returns nil
|
||||
if d == nil {
|
||||
if e != nil {
|
||||
t.Fatalf("expected nil got %v", e)
|
||||
}
|
||||
if de != nil {
|
||||
t.Fatalf("expected nil got %v", de)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// check encoded map
|
||||
name := e["endpoint"]
|
||||
desc := e["description"]
|
||||
method := strings.Split(e["method"], ",")
|
||||
path := strings.Split(e["path"], ",")
|
||||
host := strings.Split(e["host"], ",")
|
||||
handler := e["handler"]
|
||||
|
||||
if name != d.Name {
|
||||
t.Fatalf("expected %v got %v", d.Name, name)
|
||||
}
|
||||
if desc != d.Description {
|
||||
t.Fatalf("expected %v got %v", d.Description, desc)
|
||||
}
|
||||
if handler != d.Handler {
|
||||
t.Fatalf("expected %v got %v", d.Handler, handler)
|
||||
}
|
||||
if ok := compare(d.Method, method); !ok {
|
||||
t.Fatalf("expected %v got %v", d.Method, method)
|
||||
}
|
||||
if ok := compare(d.Path, path); !ok {
|
||||
t.Fatalf("expected %v got %v", d.Path, path)
|
||||
}
|
||||
if ok := compare(d.Host, host); !ok {
|
||||
t.Fatalf("expected %v got %v", d.Host, host)
|
||||
}
|
||||
|
||||
if de.Name != d.Name {
|
||||
t.Fatalf("expected %v got %v", d.Name, de.Name)
|
||||
}
|
||||
if de.Description != d.Description {
|
||||
t.Fatalf("expected %v got %v", d.Description, de.Description)
|
||||
}
|
||||
if de.Handler != d.Handler {
|
||||
t.Fatalf("expected %v got %v", d.Handler, de.Handler)
|
||||
}
|
||||
if ok := compare(d.Method, de.Method); !ok {
|
||||
t.Fatalf("expected %v got %v", d.Method, de.Method)
|
||||
}
|
||||
if ok := compare(d.Path, de.Path); !ok {
|
||||
t.Fatalf("expected %v got %v", d.Path, de.Path)
|
||||
}
|
||||
if ok := compare(d.Host, de.Host); !ok {
|
||||
t.Fatalf("expected %v got %v", d.Host, de.Host)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Package api provides an http-rpc handler which provides the entire http request over rpc
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
goapi "github.com/micro/go-micro/api"
|
||||
"github.com/micro/go-micro/api/handler"
|
||||
api "github.com/micro/go-micro/api/proto"
|
||||
"github.com/micro/go-micro/client"
|
||||
"github.com/micro/go-micro/client/selector"
|
||||
"github.com/micro/go-micro/errors"
|
||||
"github.com/micro/go-micro/util/ctx"
|
||||
)
|
||||
|
||||
type apiHandler struct {
|
||||
opts handler.Options
|
||||
s *goapi.Service
|
||||
}
|
||||
|
||||
const (
|
||||
Handler = "api"
|
||||
)
|
||||
|
||||
// API handler is the default handler which takes api.Request and returns api.Response
|
||||
func (a *apiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
request, err := requestToProto(r)
|
||||
if err != nil {
|
||||
er := errors.InternalServerError("go.micro.api", err.Error())
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(500)
|
||||
w.Write([]byte(er.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
var service *goapi.Service
|
||||
|
||||
if a.s != nil {
|
||||
// we were given the service
|
||||
service = a.s
|
||||
} else if a.opts.Router != nil {
|
||||
// try get service from router
|
||||
s, err := a.opts.Router.Route(r)
|
||||
if err != nil {
|
||||
er := errors.InternalServerError("go.micro.api", err.Error())
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(500)
|
||||
w.Write([]byte(er.Error()))
|
||||
return
|
||||
}
|
||||
service = s
|
||||
} else {
|
||||
// we have no way of routing the request
|
||||
er := errors.InternalServerError("go.micro.api", "no route found")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(500)
|
||||
w.Write([]byte(er.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// create request and response
|
||||
c := a.opts.Service.Client()
|
||||
req := c.NewRequest(service.Name, service.Endpoint.Name, request)
|
||||
rsp := &api.Response{}
|
||||
|
||||
// create the context from headers
|
||||
cx := ctx.FromRequest(r)
|
||||
// create strategy
|
||||
so := selector.WithStrategy(strategy(service.Services))
|
||||
|
||||
if err := c.Call(cx, req, rsp, client.WithSelectOption(so)); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
ce := errors.Parse(err.Error())
|
||||
switch ce.Code {
|
||||
case 0:
|
||||
w.WriteHeader(500)
|
||||
default:
|
||||
w.WriteHeader(int(ce.Code))
|
||||
}
|
||||
w.Write([]byte(ce.Error()))
|
||||
return
|
||||
} else if rsp.StatusCode == 0 {
|
||||
rsp.StatusCode = http.StatusOK
|
||||
}
|
||||
|
||||
for _, header := range rsp.GetHeader() {
|
||||
for _, val := range header.Values {
|
||||
w.Header().Add(header.Key, val)
|
||||
}
|
||||
}
|
||||
|
||||
if len(w.Header().Get("Content-Type")) == 0 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
w.WriteHeader(int(rsp.StatusCode))
|
||||
w.Write([]byte(rsp.Body))
|
||||
}
|
||||
|
||||
func (a *apiHandler) String() string {
|
||||
return "api"
|
||||
}
|
||||
|
||||
func NewHandler(opts ...handler.Option) handler.Handler {
|
||||
options := handler.NewOptions(opts...)
|
||||
return &apiHandler{
|
||||
opts: options,
|
||||
}
|
||||
}
|
||||
|
||||
func WithService(s *goapi.Service, opts ...handler.Option) handler.Handler {
|
||||
options := handler.NewOptions(opts...)
|
||||
return &apiHandler{
|
||||
opts: options,
|
||||
s: s,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
api "github.com/micro/go-micro/api/proto"
|
||||
"github.com/micro/go-micro/client/selector"
|
||||
"github.com/micro/go-micro/registry"
|
||||
)
|
||||
|
||||
func requestToProto(r *http.Request) (*api.Request, error) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return nil, fmt.Errorf("Error parsing form: %v", err)
|
||||
}
|
||||
|
||||
req := &api.Request{
|
||||
Path: r.URL.Path,
|
||||
Method: r.Method,
|
||||
Header: make(map[string]*api.Pair),
|
||||
Get: make(map[string]*api.Pair),
|
||||
Post: make(map[string]*api.Pair),
|
||||
Url: r.URL.String(),
|
||||
}
|
||||
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
ct = "application/x-www-form-urlencoded"
|
||||
r.Header.Set("Content-Type", ct)
|
||||
}
|
||||
|
||||
switch ct {
|
||||
case "application/x-www-form-urlencoded":
|
||||
// expect form vals
|
||||
default:
|
||||
data, _ := ioutil.ReadAll(r.Body)
|
||||
req.Body = string(data)
|
||||
}
|
||||
|
||||
// Set X-Forwarded-For if it does not exist
|
||||
if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
if prior, ok := r.Header["X-Forwarded-For"]; ok {
|
||||
ip = strings.Join(prior, ", ") + ", " + ip
|
||||
}
|
||||
|
||||
// Set the header
|
||||
req.Header["X-Forwarded-For"] = &api.Pair{
|
||||
Key: "X-Forwarded-For",
|
||||
Values: []string{ip},
|
||||
}
|
||||
}
|
||||
|
||||
// Host is stripped from net/http Headers so let's add it
|
||||
req.Header["Host"] = &api.Pair{
|
||||
Key: "Host",
|
||||
Values: []string{r.Host},
|
||||
}
|
||||
|
||||
// Get data
|
||||
for key, vals := range r.URL.Query() {
|
||||
header, ok := req.Get[key]
|
||||
if !ok {
|
||||
header = &api.Pair{
|
||||
Key: key,
|
||||
}
|
||||
req.Get[key] = header
|
||||
}
|
||||
header.Values = vals
|
||||
}
|
||||
|
||||
// Post data
|
||||
for key, vals := range r.PostForm {
|
||||
header, ok := req.Post[key]
|
||||
if !ok {
|
||||
header = &api.Pair{
|
||||
Key: key,
|
||||
}
|
||||
req.Post[key] = header
|
||||
}
|
||||
header.Values = vals
|
||||
}
|
||||
|
||||
for key, vals := range r.Header {
|
||||
header, ok := req.Header[key]
|
||||
if !ok {
|
||||
header = &api.Pair{
|
||||
Key: key,
|
||||
}
|
||||
req.Header[key] = header
|
||||
}
|
||||
header.Values = vals
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// strategy is a hack for selection
|
||||
func strategy(services []*registry.Service) selector.Strategy {
|
||||
return func(_ []*registry.Service) selector.Next {
|
||||
// ignore input to this function, use services above
|
||||
return selector.Random(services)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRequestToProto(t *testing.T) {
|
||||
testData := []*http.Request{
|
||||
&http.Request{
|
||||
Method: "GET",
|
||||
Header: http.Header{
|
||||
"Header": []string{"test"},
|
||||
},
|
||||
URL: &url.URL{
|
||||
Scheme: "http",
|
||||
Host: "localhost",
|
||||
Path: "/foo/bar",
|
||||
RawQuery: "param1=value1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, d := range testData {
|
||||
p, err := requestToProto(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Path != d.URL.Path {
|
||||
t.Fatalf("Expected path %s got %s", d.URL.Path, p.Path)
|
||||
}
|
||||
if p.Method != d.Method {
|
||||
t.Fatalf("Expected method %s got %s", d.Method, p.Method)
|
||||
}
|
||||
for k, v := range d.Header {
|
||||
if val, ok := p.Header[k]; !ok {
|
||||
t.Fatalf("Expected header %s", k)
|
||||
} else {
|
||||
if val.Values[0] != v[0] {
|
||||
t.Fatalf("Expected val %s, got %s", val.Values[0], v[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// Package broker provides a go-micro/broker handler
|
||||
package broker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/micro/go-micro/api/handler"
|
||||
"github.com/micro/go-micro/broker"
|
||||
"github.com/micro/go-micro/util/log"
|
||||
)
|
||||
|
||||
const (
|
||||
Handler = "broker"
|
||||
|
||||
pingTime = (readDeadline * 9) / 10
|
||||
readLimit = 16384
|
||||
readDeadline = 60 * time.Second
|
||||
writeDeadline = 10 * time.Second
|
||||
)
|
||||
|
||||
type brokerHandler struct {
|
||||
opts handler.Options
|
||||
u websocket.Upgrader
|
||||
}
|
||||
|
||||
type conn struct {
|
||||
b broker.Broker
|
||||
cType string
|
||||
topic string
|
||||
queue string
|
||||
exit chan bool
|
||||
|
||||
sync.Mutex
|
||||
ws *websocket.Conn
|
||||
}
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
contentType = "text/plain"
|
||||
)
|
||||
|
||||
func checkOrigin(r *http.Request) bool {
|
||||
origin := r.Header["Origin"]
|
||||
if len(origin) == 0 {
|
||||
return true
|
||||
}
|
||||
u, err := url.Parse(origin[0])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return u.Host == r.Host
|
||||
}
|
||||
|
||||
func (c *conn) close() {
|
||||
select {
|
||||
case <-c.exit:
|
||||
return
|
||||
default:
|
||||
close(c.exit)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *conn) readLoop() {
|
||||
defer func() {
|
||||
c.close()
|
||||
c.ws.Close()
|
||||
}()
|
||||
|
||||
// set read limit/deadline
|
||||
c.ws.SetReadLimit(readLimit)
|
||||
c.ws.SetReadDeadline(time.Now().Add(readDeadline))
|
||||
|
||||
// set close handler
|
||||
ch := c.ws.CloseHandler()
|
||||
c.ws.SetCloseHandler(func(code int, text string) error {
|
||||
err := ch(code, text)
|
||||
c.close()
|
||||
return err
|
||||
})
|
||||
|
||||
// set pong handler
|
||||
c.ws.SetPongHandler(func(string) error {
|
||||
c.ws.SetReadDeadline(time.Now().Add(readDeadline))
|
||||
return nil
|
||||
})
|
||||
|
||||
for {
|
||||
_, message, err := c.ws.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.b.Publish(c.topic, &broker.Message{
|
||||
Header: map[string]string{"Content-Type": c.cType},
|
||||
Body: message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *conn) write(mType int, data []byte) error {
|
||||
c.Lock()
|
||||
c.ws.SetWriteDeadline(time.Now().Add(writeDeadline))
|
||||
err := c.ws.WriteMessage(mType, data)
|
||||
c.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *conn) writeLoop() {
|
||||
ticker := time.NewTicker(pingTime)
|
||||
|
||||
var opts []broker.SubscribeOption
|
||||
|
||||
if len(c.queue) > 0 {
|
||||
opts = append(opts, broker.Queue(c.queue))
|
||||
}
|
||||
|
||||
subscriber, err := c.b.Subscribe(c.topic, func(p broker.Event) error {
|
||||
b, err := json.Marshal(p.Message())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return c.write(websocket.TextMessage, b)
|
||||
}, opts...)
|
||||
|
||||
defer func() {
|
||||
subscriber.Unsubscribe()
|
||||
ticker.Stop()
|
||||
c.ws.Close()
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
log.Log(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := c.write(websocket.PingMessage, []byte{}); err != nil {
|
||||
return
|
||||
}
|
||||
case <-c.exit:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *brokerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
br := b.opts.Service.Client().Options().Broker
|
||||
|
||||
// Setup the broker
|
||||
once.Do(func() {
|
||||
br.Init()
|
||||
br.Connect()
|
||||
})
|
||||
|
||||
// Parse
|
||||
r.ParseForm()
|
||||
topic := r.Form.Get("topic")
|
||||
|
||||
// Can't do anything without a topic
|
||||
if len(topic) == 0 {
|
||||
http.Error(w, "Topic not specified", 400)
|
||||
return
|
||||
}
|
||||
|
||||
// Post assumed to be Publish
|
||||
if r.Method == "POST" {
|
||||
// Create a broker message
|
||||
msg := &broker.Message{
|
||||
Header: make(map[string]string),
|
||||
}
|
||||
|
||||
// Set header
|
||||
for k, v := range r.Header {
|
||||
msg.Header[k] = strings.Join(v, ", ")
|
||||
}
|
||||
|
||||
// Read body
|
||||
b, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
// Set body
|
||||
msg.Body = b
|
||||
|
||||
// Publish
|
||||
br.Publish(topic, msg)
|
||||
return
|
||||
}
|
||||
|
||||
// now back to our regularly scheduled programming
|
||||
|
||||
if r.Method != "GET" {
|
||||
http.Error(w, "Method not allowed", 405)
|
||||
return
|
||||
}
|
||||
|
||||
queue := r.Form.Get("queue")
|
||||
|
||||
ws, err := b.u.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Log(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
cType := r.Header.Get("Content-Type")
|
||||
if len(cType) == 0 {
|
||||
cType = contentType
|
||||
}
|
||||
|
||||
c := &conn{
|
||||
b: br,
|
||||
cType: cType,
|
||||
topic: topic,
|
||||
queue: queue,
|
||||
exit: make(chan bool),
|
||||
ws: ws,
|
||||
}
|
||||
|
||||
go c.writeLoop()
|
||||
c.readLoop()
|
||||
}
|
||||
|
||||
func (b *brokerHandler) String() string {
|
||||
return "broker"
|
||||
}
|
||||
|
||||
func NewHandler(opts ...handler.Option) handler.Handler {
|
||||
return &brokerHandler{
|
||||
u: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
},
|
||||
opts: handler.NewOptions(opts...),
|
||||
}
|
||||
}
|
||||
|
||||
func WithCors(cors map[string]bool, opts ...handler.Option) handler.Handler {
|
||||
return &brokerHandler{
|
||||
u: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
if origin := r.Header.Get("Origin"); cors[origin] {
|
||||
return true
|
||||
} else if len(origin) > 0 && cors["*"] {
|
||||
return true
|
||||
} else if checkOrigin(r) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
},
|
||||
opts: handler.NewOptions(opts...),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Package cloudevents provides a cloudevents handler publishing the event using the go-micro/client
|
||||
package cloudevents
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/micro/go-micro/api/handler"
|
||||
"github.com/micro/go-micro/util/ctx"
|
||||
)
|
||||
|
||||
type event struct {
|
||||
options handler.Options
|
||||
}
|
||||
|
||||
var (
|
||||
Handler = "cloudevents"
|
||||
versionRe = regexp.MustCompilePOSIX("^v[0-9]+$")
|
||||
)
|
||||
|
||||
func eventName(parts []string) string {
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
func evRoute(ns, p string) (string, string) {
|
||||
p = path.Clean(p)
|
||||
p = strings.TrimPrefix(p, "/")
|
||||
|
||||
if len(p) == 0 {
|
||||
return ns, "event"
|
||||
}
|
||||
|
||||
parts := strings.Split(p, "/")
|
||||
|
||||
// no path
|
||||
if len(parts) == 0 {
|
||||
// topic: namespace
|
||||
// action: event
|
||||
return strings.Trim(ns, "."), "event"
|
||||
}
|
||||
|
||||
// Treat /v[0-9]+ as versioning
|
||||
// /v1/foo/bar => topic: v1.foo action: bar
|
||||
if len(parts) >= 2 && versionRe.Match([]byte(parts[0])) {
|
||||
topic := ns + "." + strings.Join(parts[:2], ".")
|
||||
action := eventName(parts[1:])
|
||||
return topic, action
|
||||
}
|
||||
|
||||
// /foo => topic: ns.foo action: foo
|
||||
// /foo/bar => topic: ns.foo action: bar
|
||||
topic := ns + "." + strings.Join(parts[:1], ".")
|
||||
action := eventName(parts[1:])
|
||||
|
||||
return topic, action
|
||||
}
|
||||
|
||||
func (e *event) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// request to topic:event
|
||||
// create event
|
||||
// publish to topic
|
||||
topic, _ := evRoute(e.options.Namespace, r.URL.Path)
|
||||
|
||||
// create event
|
||||
ev, err := FromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
// get client
|
||||
c := e.options.Service.Client()
|
||||
|
||||
// create publication
|
||||
p := c.NewMessage(topic, ev)
|
||||
|
||||
// publish event
|
||||
if err := c.Publish(ctx.FromRequest(r), p); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (e *event) String() string {
|
||||
return "cloudevents"
|
||||
}
|
||||
|
||||
func NewHandler(opts ...handler.Option) handler.Handler {
|
||||
return &event{
|
||||
options: handler.NewOptions(opts...),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/*
|
||||
* From: https://github.com/serverless/event-gateway/blob/master/event/event.go
|
||||
* Modified: Strip to handler requirements
|
||||
*
|
||||
* Copyright 2017 Serverless, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package cloudevents
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gopkg.in/go-playground/validator.v9"
|
||||
)
|
||||
|
||||
const (
|
||||
// TransformationVersion is indicative of the revision of how Event Gateway transforms a request into CloudEvents format.
|
||||
TransformationVersion = "0.1"
|
||||
|
||||
// CloudEventsVersion currently supported by Event Gateway
|
||||
CloudEventsVersion = "0.1"
|
||||
)
|
||||
|
||||
// Event is a default event structure. All data that passes through the Event Gateway
|
||||
// is formatted to a format defined CloudEvents v0.1 spec.
|
||||
type Event struct {
|
||||
EventType string `json:"eventType" validate:"required"`
|
||||
EventTypeVersion string `json:"eventTypeVersion,omitempty"`
|
||||
CloudEventsVersion string `json:"cloudEventsVersion" validate:"required"`
|
||||
Source string `json:"source" validate:"uri,required"`
|
||||
EventID string `json:"eventID" validate:"required"`
|
||||
EventTime *time.Time `json:"eventTime,omitempty"`
|
||||
SchemaURL string `json:"schemaURL,omitempty"`
|
||||
Extensions map[string]interface{} `json:"extensions,omitempty"`
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// New return new instance of Event.
|
||||
func New(eventType string, mimeType string, payload interface{}) *Event {
|
||||
now := time.Now()
|
||||
|
||||
event := &Event{
|
||||
EventType: eventType,
|
||||
CloudEventsVersion: CloudEventsVersion,
|
||||
Source: "https://micro.mu",
|
||||
EventID: uuid.New().String(),
|
||||
EventTime: &now,
|
||||
ContentType: mimeType,
|
||||
Data: payload,
|
||||
Extensions: map[string]interface{}{
|
||||
"eventgateway": map[string]interface{}{
|
||||
"transformed": "true",
|
||||
"transformation-version": TransformationVersion,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
event.Data = normalizePayload(event.Data, event.ContentType)
|
||||
return event
|
||||
}
|
||||
|
||||
// FromRequest takes an HTTP request and returns an Event along with path. Most of the implementation
|
||||
// is based on https://github.com/cloudevents/spec/blob/master/http-transport-binding.md.
|
||||
// This function also supports legacy mode where event type is sent in Event header.
|
||||
func FromRequest(r *http.Request) (*Event, error) {
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
mimeType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
if err.Error() != "mime: no media type" {
|
||||
return nil, err
|
||||
}
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
// Read request body
|
||||
body := []byte{}
|
||||
if r.Body != nil {
|
||||
body, err = ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var event *Event
|
||||
if mimeType == mimeCloudEventsJSON { // CloudEvents Structured Content Mode
|
||||
return parseAsCloudEvent(mimeType, body)
|
||||
} else if isCloudEventsBinaryContentMode(r.Header) { // CloudEvents Binary Content Mode
|
||||
return parseAsCloudEventBinary(r.Header, body)
|
||||
} else if isLegacyMode(r.Header) {
|
||||
if mimeType == mimeJSON { // CloudEvent in Legacy Mode
|
||||
event, err = parseAsCloudEvent(mimeType, body)
|
||||
if err != nil {
|
||||
return New(string(r.Header.Get("event")), mimeType, body), nil
|
||||
}
|
||||
return event, err
|
||||
}
|
||||
|
||||
return New(string(r.Header.Get("event")), mimeType, body), nil
|
||||
}
|
||||
|
||||
return New("http.request", mimeJSON, newHTTPRequestData(r, body)), nil
|
||||
}
|
||||
|
||||
// Validate Event struct
|
||||
func (e *Event) Validate() error {
|
||||
validate := validator.New()
|
||||
err := validate.Struct(e)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CloudEvent not valid: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isLegacyMode(headers http.Header) bool {
|
||||
if headers.Get("Event") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isCloudEventsBinaryContentMode(headers http.Header) bool {
|
||||
if headers.Get("CE-EventType") != "" &&
|
||||
headers.Get("CE-CloudEventsVersion") != "" &&
|
||||
headers.Get("CE-Source") != "" &&
|
||||
headers.Get("CE-EventID") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func parseAsCloudEventBinary(headers http.Header, payload interface{}) (*Event, error) {
|
||||
event := &Event{
|
||||
EventType: headers.Get("CE-EventType"),
|
||||
EventTypeVersion: headers.Get("CE-EventTypeVersion"),
|
||||
CloudEventsVersion: headers.Get("CE-CloudEventsVersion"),
|
||||
Source: headers.Get("CE-Source"),
|
||||
EventID: headers.Get("CE-EventID"),
|
||||
ContentType: headers.Get("Content-Type"),
|
||||
Data: payload,
|
||||
}
|
||||
|
||||
err := event.Validate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if headers.Get("CE-EventTime") != "" {
|
||||
val, err := time.Parse(time.RFC3339, headers.Get("CE-EventTime"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
event.EventTime = &val
|
||||
}
|
||||
|
||||
if val := headers.Get("CE-SchemaURL"); len(val) > 0 {
|
||||
event.SchemaURL = val
|
||||
}
|
||||
|
||||
event.Extensions = map[string]interface{}{}
|
||||
for key, val := range flatten(headers) {
|
||||
if strings.HasPrefix(key, "Ce-X-") {
|
||||
key = strings.TrimLeft(key, "Ce-X-")
|
||||
// Make first character lowercase
|
||||
runes := []rune(key)
|
||||
runes[0] = unicode.ToLower(runes[0])
|
||||
event.Extensions[string(runes)] = val
|
||||
}
|
||||
}
|
||||
|
||||
event.Data = normalizePayload(event.Data, event.ContentType)
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func flatten(h http.Header) map[string]string {
|
||||
headers := map[string]string{}
|
||||
for key, header := range h {
|
||||
headers[key] = header[0]
|
||||
if len(header) > 1 {
|
||||
headers[key] = strings.Join(header, ", ")
|
||||
}
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
func parseAsCloudEvent(mime string, payload interface{}) (*Event, error) {
|
||||
body, ok := payload.([]byte)
|
||||
if ok {
|
||||
event := &Event{}
|
||||
err := json.Unmarshal(body, event)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = event.Validate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
event.Data = normalizePayload(event.Data, event.ContentType)
|
||||
return event, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("couldn't cast to []byte")
|
||||
}
|
||||
|
||||
const (
|
||||
mimeJSON = "application/json"
|
||||
mimeFormMultipart = "multipart/form-data"
|
||||
mimeFormURLEncoded = "application/x-www-form-urlencoded"
|
||||
mimeCloudEventsJSON = "application/cloudevents+json"
|
||||
)
|
||||
|
||||
// normalizePayload takes anything, checks if it's []byte array and depending on provided mime
|
||||
// type converts it to either string or map[string]interface to avoid having base64 string after
|
||||
// JSON marshaling.
|
||||
func normalizePayload(payload interface{}, mime string) interface{} {
|
||||
if bytePayload, ok := payload.([]byte); ok && len(bytePayload) > 0 {
|
||||
switch {
|
||||
case mime == mimeJSON || strings.HasSuffix(mime, "+json"):
|
||||
var result map[string]interface{}
|
||||
err := json.Unmarshal(bytePayload, &result)
|
||||
if err != nil {
|
||||
return payload
|
||||
}
|
||||
return result
|
||||
case strings.HasPrefix(mime, mimeFormMultipart), mime == mimeFormURLEncoded:
|
||||
return string(bytePayload)
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
// HTTPRequestData is a event schema used for sending events to HTTP subscriptions.
|
||||
type HTTPRequestData struct {
|
||||
Headers map[string]string `json:"headers"`
|
||||
Query map[string][]string `json:"query"`
|
||||
Body interface{} `json:"body"`
|
||||
Host string `json:"host"`
|
||||
Path string `json:"path"`
|
||||
Method string `json:"method"`
|
||||
Params map[string]string `json:"params"`
|
||||
}
|
||||
|
||||
// NewHTTPRequestData returns a new instance of HTTPRequestData
|
||||
func newHTTPRequestData(r *http.Request, eventData interface{}) *HTTPRequestData {
|
||||
req := &HTTPRequestData{
|
||||
Headers: flatten(r.Header),
|
||||
Query: r.URL.Query(),
|
||||
Body: eventData,
|
||||
Host: r.Host,
|
||||
Path: r.URL.Path,
|
||||
Method: r.Method,
|
||||
}
|
||||
|
||||
req.Body = normalizePayload(req.Body, r.Header.Get("content-type"))
|
||||
return req
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Package event provides a handler which publishes an event
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/micro/go-micro/api/handler"
|
||||
proto "github.com/micro/go-micro/api/proto"
|
||||
"github.com/micro/go-micro/util/ctx"
|
||||
)
|
||||
|
||||
type event struct {
|
||||
options handler.Options
|
||||
}
|
||||
|
||||
var (
|
||||
Handler = "event"
|
||||
versionRe = regexp.MustCompilePOSIX("^v[0-9]+$")
|
||||
)
|
||||
|
||||
func eventName(parts []string) string {
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
func evRoute(ns, p string) (string, string) {
|
||||
p = path.Clean(p)
|
||||
p = strings.TrimPrefix(p, "/")
|
||||
|
||||
if len(p) == 0 {
|
||||
return ns, "event"
|
||||
}
|
||||
|
||||
parts := strings.Split(p, "/")
|
||||
|
||||
// no path
|
||||
if len(parts) == 0 {
|
||||
// topic: namespace
|
||||
// action: event
|
||||
return strings.Trim(ns, "."), "event"
|
||||
}
|
||||
|
||||
// Treat /v[0-9]+ as versioning
|
||||
// /v1/foo/bar => topic: v1.foo action: bar
|
||||
if len(parts) >= 2 && versionRe.Match([]byte(parts[0])) {
|
||||
topic := ns + "." + strings.Join(parts[:2], ".")
|
||||
action := eventName(parts[1:])
|
||||
return topic, action
|
||||
}
|
||||
|
||||
// /foo => topic: ns.foo action: foo
|
||||
// /foo/bar => topic: ns.foo action: bar
|
||||
topic := ns + "." + strings.Join(parts[:1], ".")
|
||||
action := eventName(parts[1:])
|
||||
|
||||
return topic, action
|
||||
}
|
||||
|
||||
func (e *event) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// request to topic:event
|
||||
// create event
|
||||
// publish to topic
|
||||
|
||||
topic, action := evRoute(e.options.Namespace, r.URL.Path)
|
||||
|
||||
// create event
|
||||
ev := &proto.Event{
|
||||
Name: action,
|
||||
// TODO: dedupe event
|
||||
Id: fmt.Sprintf("%s-%s-%s", topic, action, uuid.New().String()),
|
||||
Header: make(map[string]*proto.Pair),
|
||||
Timestamp: time.Now().Unix(),
|
||||
}
|
||||
|
||||
// set headers
|
||||
for key, vals := range r.Header {
|
||||
header, ok := ev.Header[key]
|
||||
if !ok {
|
||||
header = &proto.Pair{
|
||||
Key: key,
|
||||
}
|
||||
ev.Header[key] = header
|
||||
}
|
||||
header.Values = vals
|
||||
}
|
||||
|
||||
// set body
|
||||
b, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
ev.Data = string(b)
|
||||
|
||||
// get client
|
||||
c := e.options.Service.Client()
|
||||
|
||||
// create publication
|
||||
p := c.NewMessage(topic, ev)
|
||||
|
||||
// publish event
|
||||
if err := c.Publish(ctx.FromRequest(r), p); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (e *event) String() string {
|
||||
return "event"
|
||||
}
|
||||
|
||||
func NewHandler(opts ...handler.Option) handler.Handler {
|
||||
return &event{
|
||||
options: handler.NewOptions(opts...),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Package file serves file relative to the current directory
|
||||
package file
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Handler struct{}
|
||||
|
||||
func (h *Handler) Serve(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "."+r.URL.Path)
|
||||
}
|
||||
|
||||
func (h *Handler) String() string {
|
||||
return "file"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Package handler provides http handlers
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Handler represents a HTTP handler that manages a request
|
||||
type Handler interface {
|
||||
// standard http handler
|
||||
http.Handler
|
||||
// name of handler
|
||||
String() string
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user