Compare commits

..

453 Commits

Author SHA1 Message Date
Asim Aslam d54139c02d Harden agent conformance retry prompts (#4195)
goreleaser / goreleaser (push) Waiting to run
Co-authored-by: Codex <codex@openai.com>
2026-07-07 00:00:00 +01:00
Asim Aslam 5ff3760cd4 Harden agent conformance retry completion (#4191)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 23:10:09 +01:00
Asim Aslam 69ab520360 docs: refresh planner priorities for 4186 (#4187)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 22:38:08 +01:00
Asim Aslam 0c4ea84ee3 harness: dedupe delegated notify paraphrases (#4185)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 22:17:10 +01:00
Asim Aslam e6a6c72038 docs: refresh planner priorities for 4180 (#4181)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 21:59:09 +01:00
Asim Aslam 7c036459cf docs: add micro loop quickstart wayfinding (#4179)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 21:23:10 +01:00
Asim Aslam 83cb4ff1a9 docs: refresh planner priorities for 4174 (#4176)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 20:47:02 +01:00
Asim Aslam 941d43bdf5 harness: dedupe delegated owner notifications (#4173)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 20:16:40 +01:00
Asim Aslam bb3e40ade7 docs: refresh planner priorities for 4168 (#4170)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 19:51:18 +01:00
Asim Aslam dbce523437 agent: cover durable checkpoint resume smoke (#4167)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 19:29:11 +01:00
Asim Aslam 98125cd770 Stabilize AtlasCloud follow-up tool fallback (#4165)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 18:37:42 +01:00
Asim Aslam 3454a08079 docs: refresh planner priorities for 4160 (#4161)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 18:10:32 +01:00
Asim Aslam decfa7c63e Fix AtlasCloud tool schema normalization (#4159)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 16:58:24 +01:00
Asim Aslam b6980e27a9 docs: refresh planner priorities for 4154 (#4155)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 16:17:30 +01:00
Asim Aslam d92d63943c Add no-secret agent debugging smoke (#4153)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 15:08:34 +01:00
Asim Aslam ec1d46526c docs: refresh planner priorities for 4147 (#4149)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 14:28:46 +01:00
Asim Aslam 40308cf779 Preserve delegated notification plan completion (#4146)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 13:03:39 +01:00
Asim Aslam e198810390 docs: refresh planner priorities for 4141 (#4143)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 12:44:39 +01:00
Asim Aslam 3ec50d1a7d Add first-agent tutorial smoke harness (#4140)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 11:06:12 +01:00
Asim Aslam 327f99cd19 docs: refresh coherence changelog (#4135)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 09:40:01 +01:00
Asim Aslam 498aac59f4 Stabilize duplicate plan-delegate notify replays (#4133)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 09:12:58 +01:00
Asim Aslam 6833b3c73c docs: refresh planner priorities for 4127 (#4131)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 08:46:21 +01:00
Asim Aslam ec27ce2e25 Fix first-agent quickstart numbering (#4125)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 07:18:03 +01:00
Asim Aslam 45cf24162b Add first-agent examples CLI wayfinding (#4124)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 07:17:16 +01:00
Asim Aslam 2778472096 docs: refresh planner priorities for 4121 (#4122)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 06:39:18 +01:00
Asim Aslam 9dfb35d85a Guard provider conformance workflow scheduling (#4120)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 04:59:35 +01:00
Asim Aslam ac230b57ee docs: refresh planner priorities for 4114 (#4116)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 04:18:25 +01:00
Asim Aslam 96eea598fc docs: align first-agent inspect command (#4113)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 03:30:28 +01:00
Asim Aslam 97391e9a92 docs: refresh planner priorities for 4109 (#4111)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 02:48:05 +01:00
Asim Aslam 6f2fefc1e1 Make plan-delegate notify replay idempotent (#4108)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 02:22:20 +01:00
Asim Aslam 224446b948 docs: refresh planner priorities for 4103 (#4105)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 01:44:17 +01:00
Asim Aslam eb16370f03 Add zero-to-hero CLI entrypoint (#4102)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 01:02:09 +01:00
Asim Aslam 3262034698 docs: refresh planner priorities for 4096 (#4098)
Co-authored-by: Codex <codex@openai.com>
2026-07-06 00:31:40 +01:00
Asim Aslam 2963ac3fa6 docs: align architecture with agent lifecycle (#4095)
goreleaser / goreleaser (push) Waiting to run
Co-authored-by: Codex <codex@openai.com>
2026-07-05 23:59:15 +01:00
Asim Aslam eaff193569 docs: refresh planner priorities for 4091 (#4093)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 23:35:22 +01:00
Asim Aslam 36fd5b7bcd Promote first-agent doctor recovery (#4090)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 22:59:00 +01:00
Asim Aslam 2e89b01295 docs: refresh planner priorities for 4085 (#4087)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 22:34:11 +01:00
Asim Aslam e95d565502 docs: add install troubleshooting on-ramp (#4084)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 21:59:12 +01:00
Asim Aslam a826e01dd4 docs: refresh planner queue for 4081 (#4082)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 21:33:35 +01:00
Asim Aslam f8619d89b6 docs: align website quickstart on-ramp (#4080)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 21:06:30 +01:00
Asim Aslam 8ddb71143a docs: refresh planner priorities for 4076 (#4078)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 20:35:10 +01:00
Asim Aslam 3699c88e10 Make config close idempotent (#4075)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 20:08:06 +01:00
Asim Aslam a2145cc3a9 docs: refresh planner priorities for 4070 (#4072)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 19:39:21 +01:00
Asim Aslam d0dce12797 test docs wayfinding link targets (#4069)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 19:01:31 +01:00
Asim Aslam 2cfa776a66 docs: refresh planner priorities for 4063 (#4065)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 18:43:42 +01:00
Asim Aslam 24a64aadb0 test examples lifecycle map (#4062)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 18:01:38 +01:00
Asim Aslam 5d3d570c4f docs: refresh planner priorities for 4058 (#4060)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 17:36:44 +01:00
Asim Aslam 304d14331c docs: lead getting started with no-secret path (#4057)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 17:09:01 +01:00
Asim Aslam 5429ff0f08 docs: refresh planner priorities for 4053 (#4055)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 16:39:33 +01:00
Asim Aslam 8c9521cc63 docs: surface agent demo after scaffolding (#4052)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 16:11:07 +01:00
Asim Aslam 621aa68f13 Lead CLI docs with agent demo (#4049)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 15:17:24 +01:00
Asim Aslam 32a337509b docs: refresh planner priorities for 4045 (#4047)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 14:43:29 +01:00
Asim Aslam 4a6ab4016d docs: add agent demo to first-agent on-ramp (#4044)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 14:04:27 +01:00
Asim Aslam ce7cff7409 docs: refresh planner priorities for 4040 (#4042)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 13:40:25 +01:00
Asim Aslam 84e37e413e feat(cli): surface no-secret agent demo (#4039)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 13:08:42 +01:00
Asim Aslam 49dfdffbb7 docs: refresh planner priorities for 4035 (#4037)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 12:43:06 +01:00
Asim Aslam 9cc49f7718 Add first-agent recovery doctor (#4034)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 12:23:30 +01:00
Asim Aslam a18050d910 docs: refresh planner priorities for 4030 (#4032)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 11:55:29 +01:00
Asim Aslam 9db77ac265 docs: route security reports through GitHub advisories (#4029)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 11:41:11 +01:00
Asim Aslam d4bfb4db5a agent: record otel events before ending spans (#4027)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 10:41:35 +01:00
Asim Aslam 75bf15a7d6 docs: refresh planner priorities for 4023 (#4024)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 10:14:52 +01:00
Asim Aslam 153f178db6 docs: refresh coherence changelog (#4021)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 09:10:28 +01:00
Asim Aslam 15af99f587 agent: extend checkpoint plan continuations (#4019)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 08:48:44 +01:00
Asim Aslam f4f0bcc459 docs: refresh planner priorities for 4016 (#4017)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 08:18:35 +01:00
Asim Aslam 5ed66e550d Stabilize AtlasCloud follow-up tool calls (#4015)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 07:05:00 +01:00
Asim Aslam 7f4c7d6771 docs: refresh planner priorities for 4010 (#4011)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 06:31:47 +01:00
Asim Aslam 3f5547e781 ai: add anthropic streaming (#4009)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 04:57:58 +01:00
Asim Aslam 12144d66a6 docs: refresh planner priorities for 4004 (#4005)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 04:16:13 +01:00
Asim Aslam f2096bf0cf Guard plan delegation ordering (#4003)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 03:30:56 +01:00
Asim Aslam f758297265 docs: refresh planner priorities for 3999 (#4000)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 02:50:30 +01:00
Asim Aslam f16b23cf76 agent: run mixed text tool calls after structured calls (#3998)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 02:18:18 +01:00
Asim Aslam 9d4133c666 docs: refresh planner priorities for 3994 (#3995)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 01:49:26 +01:00
Asim Aslam 6ecfcd5cdf cli: surface first-agent next steps (#3993)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 00:59:18 +01:00
Asim Aslam 3ceb23e9cb docs: refresh planner queue for 3987 (#3988)
Co-authored-by: Codex <codex@openai.com>
2026-07-05 00:32:58 +01:00
Asim Aslam af4d81cba3 harness: require notify inside plan delegate flow (#3986)
goreleaser / goreleaser (push) Waiting to run
Co-authored-by: Codex <codex@openai.com>
2026-07-05 00:00:26 +01:00
Asim Aslam 021e3e1bd9 docs: refresh planner priorities for 3982 (#3984)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 23:33:46 +01:00
Asim Aslam 3f17f7b106 Stabilize first-agent broker isolation (#3981)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 23:01:12 +01:00
Asim Aslam 4ed4fca47d docs: align planner queue after 3975 (#3979)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 22:31:56 +01:00
Asim Aslam c2b11a1d31 docs: refresh planner queue for 3974 (#3978)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 22:30:50 +01:00
Asim Aslam f7c2ff0a74 docs: surface first-agent example path (#3975)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 22:22:42 +01:00
Asim Aslam ddf26acc47 docs: refresh planner priorities for 3968 (#3970)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 21:30:07 +01:00
Asim Aslam 54a80bc930 harness: verify first-agent on-ramp (#3967)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 21:05:30 +01:00
Asim Aslam 76f309f253 agent: alias text tool create calls to add (#3964)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 19:59:32 +01:00
Asim Aslam 9a3e6b7e5e docs(priorities): refresh planner queue for 3960 (#3961)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 19:28:25 +01:00
Asim Aslam eb76367b0b Stabilize agent conformance marker retry (#3959)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 19:02:09 +01:00
Asim Aslam 58b8eb3e6b docs(priorities): refresh planner queue for 3954 (#3956)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 18:29:59 +01:00
Asim Aslam f12788bd80 Preserve completed agent plan steps (#3953)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 17:59:30 +01:00
Asim Aslam 47997134af docs(priorities): refresh planner queue for 3949 (#3950)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 17:31:21 +01:00
Asim Aslam f35274af00 agent: harden AtlasCloud delegate conformance prompt (#3948)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 17:01:03 +01:00
Asim Aslam fefe3e5b4a docs(priorities): refresh planner queue for 3943 (#3944)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 16:36:35 +01:00
Asim Aslam b01fd477b5 agent: parse tagged text tool calls (#3942)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 16:13:07 +01:00
Asim Aslam dbca408982 docs(priorities): refresh planner queue for 3938 (#3939)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 15:42:04 +01:00
Asim Aslam fa9806c5a6 agent: retry missing delegate conformance (#3937)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 15:20:36 +01:00
Asim Aslam 95e7c387b2 docs(priorities): refresh planner queue for 3932 (#3933)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 14:36:37 +01:00
Asim Aslam cc54ae988d agent: retry conformance when tool is skipped (#3931)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 14:01:46 +01:00
Asim Aslam 6b86bbb27e docs(priorities): refresh planner queue for 3927 (#3928)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 13:34:42 +01:00
Asim Aslam 45624a25e0 Stabilize plan-delegate notify wait (#3926)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 13:06:43 +01:00
Asim Aslam e71e0a79fb docs(priorities): refresh planner queue for 3921 (#3922)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 12:41:51 +01:00
Asim Aslam ebc315505b examples: add smallest first agent (#3920)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 12:13:08 +01:00
Asim Aslam 100c7e11b9 docs(priorities): refresh planner queue for 3913 (#3915)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 11:41:35 +01:00
Asim Aslam 96ecf67573 agent: surface checkpoint resume hints in inspect (#3912)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 11:26:58 +01:00
Asim Aslam 71f4f6ac05 docs(priorities): refresh planner queue for 3907 (#3909)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 10:51:37 +01:00
Asim Aslam 0dd5ebe789 test(agent): broaden provider conformance scenario (#3906)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 10:32:49 +01:00
Asim Aslam b29c8c95b1 docs(priorities): refresh planner queue for 3900 (#3904)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 10:06:16 +01:00
Asim Aslam 47ff1bc6f5 Add AP2 mandate foundation for A2A (#3899)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 09:35:01 +01:00
Asim Aslam 6c6ce0e2a7 docs: update coherence changelog (#3897)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 09:14:57 +01:00
Asim Aslam d91ac00da9 agent: add operational failure guidance (#3895)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 08:38:33 +01:00
Asim Aslam ff94e8b316 docs(priorities): refresh planner queue for 3890 (#3892)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 08:15:25 +01:00
Asim Aslam ab0bf29c79 feat(loop): add a security role that vets for vulnerabilities (#3818)
Adds an opt-in `security` role to `micro loop` and wires it into go-micro's own
loop. On a schedule it dispatches the agent to audit the codebase for real,
exploitable vulnerabilities and file them.

Security gets a deliberately more conservative policy than the other roles,
encoded in .github/loop/prompts/security.md:
- NEVER auto-merges a security change (fixes stay human-reviewed).
- NEVER publishes exploit detail / PoC in a public issue — novel exploitable
  findings get a concise `security` + `needs-human` issue (class, location,
  impact) routed to private disclosure; only known/public dep CVEs get a
  bump PR (no auto-merge).
- Weekly by default (`--security-cron`, 0 6 * * 1); tunable.

The go-micro prompt targets its real attack surface: MCP/A2A gateways, x402
payments, JWT/wrapper auth, provider BaseURL SSRF + key leakage, the agent
tool loop (prompt injection / guardrail bypass), TLS defaults, the loop's own
PAT, and dependency CVEs via govulncheck.

Note: an agent review is not a gate. The deterministic companion — govulncheck
as a required CI check — is a recommended follow-up so known-vulnerable deps
can't merge at all.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-04 07:03:08 +01:00
Asim Aslam 96e822d656 Add no-secret debug transcript checkpoint (#3889)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 06:57:18 +01:00
Asim Aslam c60c03d485 docs(priorities): refresh planner queue for 3885 (#3886)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 06:25:38 +01:00
Asim Aslam 7963342999 docs: test first-agent wayfinding (#3884)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 04:55:59 +01:00
Asim Aslam 593846093f docs(priorities): refresh planner queue for 3878 (#3881)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 04:10:29 +01:00
Asim Aslam 5b27c5b239 Fix plan-delegate notify completion race (#3877)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 03:29:05 +01:00
Asim Aslam 0a10655231 docs(priorities): refresh planner queue for 3873 (#3874)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 02:49:04 +01:00
Asim Aslam cc3502d156 Trace agent model streams (#3872)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 02:17:16 +01:00
Asim Aslam 014a6431f2 docs(priorities): refresh planner queue for 3866 (#3868)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 01:46:02 +01:00
Asim Aslam 3725a53bf9 Record streamed agent replies in memory (#3865)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 01:01:04 +01:00
Asim Aslam b24b19f191 docs(priorities): refresh planner queue for 3862 (#3863)
Co-authored-by: Codex <codex@openai.com>
2026-07-04 00:34:46 +01:00
Asim Aslam bc732bb513 docs: surface durable agent example (#3861)
goreleaser / goreleaser (push) Waiting to run
Co-authored-by: Codex <codex@openai.com>
2026-07-04 00:02:40 +01:00
Asim Aslam 522786d21d docs(priorities): refresh planner queue for 3856 (#3858)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 23:25:03 +01:00
Asim Aslam 84cc4c6e8d Trace agent run event kinds (#3855)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 23:00:08 +01:00
Asim Aslam f96b1014a2 docs(priorities): refresh planner queue for 3852 (#3853)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 22:34:49 +01:00
Asim Aslam 3cafff8789 Make agent preflight failures actionable (#3851)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 22:00:02 +01:00
Asim Aslam 69e23eb1f6 docs(priorities): refresh planner queue for 3846 (#3848)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 21:34:55 +01:00
Asim Aslam 1841599980 Ensure canceled agent runs fail after tool calls (#3845)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 21:07:58 +01:00
Asim Aslam 6f9994b3ea docs(priorities): refresh planner queue for 3840 (#3842)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 20:33:13 +01:00
Asim Aslam 55070f1597 Harden A2A fallback stream validation (#3839)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 20:03:40 +01:00
Asim Aslam b3940860b6 docs(priorities): refresh planner queue for 3835 (#3836)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 19:39:51 +01:00
Asim Aslam 5c38a23fe4 Classify plan-delegate timeout side effects (#3834)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 19:07:08 +01:00
Asim Aslam 9e7273dde1 docs(priorities): refresh planner queue for 3829 (#3830)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 18:42:19 +01:00
Asim Aslam 42080b6d6d Stabilize file store suffix expiry test (#3828)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 18:18:26 +01:00
Asim Aslam c3df8b833d docs(priorities): refresh planner queue for 3823 (#3824)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 17:38:33 +01:00
Asim Aslam 8694e3a20c test(store): isolate file store tests (#3822)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 17:18:57 +01:00
Asim Aslam 9def03d4e3 docs(priorities): refresh planner queue for 3817 (#3819)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 16:51:21 +01:00
Asim Aslam bd7625f947 Tighten delegated notify harness prompt (#3816)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 16:25:49 +01:00
Asim Aslam 0a547fbe17 docs(priorities): refresh planner queue for 3812 (#3813)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 15:55:48 +01:00
Asim Aslam 331e3f32bd Fix universe concierge notify handoff (#3811)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 15:31:48 +01:00
Asim Aslam 7db1d986ab docs(priorities): refresh planner queue for 3808 (#3809)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 14:48:47 +01:00
Asim Aslam 6042a6c5f5 Add CLI docs wayfinding (#3807)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 14:21:34 +01:00
Asim Aslam bff916a3ac docs(priorities): refresh planner queue for 3800 (#3802)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 13:42:57 +01:00
Asim Aslam b8c03dafa2 Stabilize plan-delegate notify recovery (#3799)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 13:30:58 +01:00
Asim Aslam 04bcef47ac docs(priorities): refresh planner queue for 3795 (#3796)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 12:58:27 +01:00
Asim Aslam e92978f3eb Add minimax to provider conformance (#3794)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 12:30:56 +01:00
Asim Aslam 695a24432a docs(priorities): refresh planner queue for 3790 (#3791)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 12:00:11 +01:00
Asim Aslam 2889b98dcf store: de-flake TestFileStoreTable timing windows (#3789)
TestFileStoreTable failed intermittently on CI ("Expected 2 items, got 1"):
the same commit passed the Unit Tests job on a PR run and failed on the master
push. Cause: records written with a 100ms expiry are read back immediately and
expected to still be present, but under `-race` on a loaded runner the write
loop + file I/O + read can exceed 100ms, so a record expires before the read.

Widen the expiry/TTL windows (100ms -> 1s) and the paired post-expiry sleeps
(-> 2s). The "read before expiry" reads happen in well under 200ms, so they stay
inside the 1s window on any runner; the "read after expiry" waits comfortably
exceed it. Test-only; the store's expiry behavior is unchanged.

Verified: `go test -race -count=5 -run TestFileStoreTable ./store/` green.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-03 11:35:56 +01:00
Asim Aslam a356ab36a8 ai/minimax: complete provider surface (matrix, conformance, changelog) (#3784)
* ai/minimax: complete provider surface (matrix, conformance, changelog)

Follow-up after merging the MiniMax provider (#3769), mirroring the Ollama
completeness pass (#3637):

- Add the `minimax` row to the AI provider capability matrix and blank-import
  ai/minimax in provider_capabilities_test.go so the matrix stays enforced
  against the registry.
- Add minimax to the stream-conformance allowlist (+ import) so its streaming
  is actually exercised against the OpenAI-compatible SSE contract, not just
  registered. It passes via the shared ai/internal/openaiapi path.
- Record the provider in CHANGELOG [Unreleased].

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

* ai: update capabilities_test provider assertions for minimax

Adding the minimax blank-import to the shared ai_test binary (for stream
conformance) also registers it for TestRegisteredProviders / TestCapabilityRows
/ TestCapabilityMatrix in capabilities_test.go, which pin the exact provider
set. Update those assertions to include minimax. (Fixes the Unit Tests failure
my scoped `-run TestStreamProviders` check missed — go compiles all _test.go in
a package into one binary.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-03 11:10:29 +01:00
Octopus 2078748e7f feat: add MiniMax provider (#3769)
Co-authored-by: octo-patch <266937838+octo-patch@users.noreply.github.com>
2026-07-03 10:56:12 +01:00
Asim Aslam d283f9b08b Accept order-scoped buyer notifications (#3783)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 10:45:47 +01:00
Asim Aslam af7b4f3d50 docs(priorities): refresh planner queue for 3777 (#3778)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 10:17:04 +01:00
Asim Aslam 954f8fa79e docs: update changelog for coherence audit (#3776)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 09:07:28 +01:00
Asim Aslam cdfe9c0947 Fix plan-delegate timeout completion (#3774)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 08:49:33 +01:00
Asim Aslam c645c5faa7 docs(priorities): refresh planner queue for 3768 (#3770)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 08:18:32 +01:00
Asim Aslam 8bde01bdac docs(priorities): refresh planner queue for 3763 (#3764)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 06:31:05 +01:00
Asim Aslam 1373ceec21 harness: parse multi-event A2A SSE (#3762)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 04:53:40 +01:00
Asim Aslam 838b7f73f8 docs(priorities): refresh planner queue for 3757 (#3758)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 04:20:35 +01:00
Asim Aslam c56423a33c Require harness notification side effects (#3756)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 03:29:12 +01:00
Asim Aslam 6edc0de7bd docs(priorities): refresh planner queue for 3752 (#3753)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 02:46:16 +01:00
Asim Aslam 3d31fe37db fix atlascloud tool-call request diagnostics (#3749)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 02:30:44 +01:00
Asim Aslam 6840ae0fb1 docs(priorities): refresh planner queue for 3745 (#3746)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 01:45:59 +01:00
Asim Aslam af61d6327a atlascloud: fall back to tool results (#3744)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 01:00:20 +01:00
Asim Aslam 79d443839d docs(priorities): refresh planner queue for 3739 (#3740)
Co-authored-by: Codex <codex@openai.com>
2026-07-03 00:33:57 +01:00
Asim Aslam 5770a2f4cb fix atlascloud stream tool fallback (#3738)
goreleaser / goreleaser (push) Waiting to run
Co-authored-by: Codex <codex@openai.com>
2026-07-03 00:06:42 +01:00
Asim Aslam 9f1f0aa191 docs(priorities): refresh planner queue for 3732 (#3733)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 23:28:01 +01:00
Asim Aslam 69ffee329e Detect duplicate plan-delegate notifications (#3731)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 23:03:37 +01:00
Asim Aslam d2c3c5e715 docs(priorities): refresh planner queue for 3727 (#3728)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 22:35:19 +01:00
Asim Aslam 11f81f40fd harness: accept order-scoped buyer notifications (#3726)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 21:59:07 +01:00
Asim Aslam a057df54f5 docs(priorities): refresh planner queue (#3722)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 21:38:35 +01:00
Asim Aslam 0da1c739fb test plan-delegate unknown tool recovery (#3720)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 21:15:09 +01:00
Asim Aslam 9ec9bf906c docs(priorities): refresh planner queue (#3716)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 20:41:47 +01:00
Asim Aslam cf790048ad loop: triage watches Lint + Run Tests too, not just the harness (#3714)
Backstop for the gate: previously loop-triage only fired on Harness (E2E)
failures, so a red lint or test on master (e.g. the misspell that slipped past
because golangci-lint isn't a required check) produced no fix issue. Now triage
watches all the gate workflows.

- micro loop: `--ci-workflow` accepts a comma-separated list of workflow names,
  rendered into the triage workflow_run trigger as a YAML array; the issue names
  the actual failed workflow via github.event.workflow_run.name. (generic CLI)
- go-micro: regenerate loop-triage.yml to watch "Harness (E2E)", "Lint",
  "Run Tests"; generalize the triage prompt beyond the harness (a lint/test
  failure on master is a real regression to fix, not a flake to ignore).
- Docs: update CONTINUOUS_IMPROVEMENT.md triage description.

Note: this is defense-in-depth. The primary fix is making golangci-lint a
required status check so red lint can't merge in the first place — that stays
with the human (branch protection).


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-02 20:21:07 +01:00
Asim Aslam 11d1711619 docs: add no-secret first-agent transcript (#3713)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 20:16:39 +01:00
Asim Aslam 7d464e8a32 docs(priorities): refresh planner queue (#3709)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 19:43:47 +01:00
Asim Aslam 8880168edd agent: continue unfinished plan steps (#3706)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 19:20:52 +01:00
Asim Aslam 33b6ab5eea docs(priorities): refresh planner queue (#3703)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 18:45:41 +01:00
Asim Aslam 32d1683d0b harness: accept buyer alias notifications (#3701)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 18:15:19 +01:00
Asim Aslam 917d60f9f8 docs(priorities): refresh planner queue (#3697)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 17:38:37 +01:00
Asim Aslam e41a80c92f docs: map examples to first-agent path (#3695)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 17:24:27 +01:00
Asim Aslam c25fe16260 docs(priorities): drop completed universe item (#3691)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 17:01:49 +01:00
Asim Aslam d240466d6d Constrain universe notify recipients (#3689)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 16:29:14 +01:00
Asim Aslam 33a4af3984 docs(priorities): refresh planner queue (#3686)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 16:00:17 +01:00
Asim Aslam 274c3b2646 Fail checkpointed runs with unfinished plans (#3684)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 15:27:52 +01:00
Asim Aslam 6b6100340b docs(priorities): refresh planner queue (#3680)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 14:40:53 +01:00
Asim Aslam 35b68b11f9 wrapper/x402: fix misspell lint failure (honour -> honor) (#3678)
golangci-lint's misspell linter fails on master: x402_settle_test.go used
British spellings ("honoured"/"honour"). Switch to US spelling to match the
linter's en_US locale. Introduced by #3676.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-02 14:39:18 +01:00
Asim Aslam 13d618ba66 test: make universe A2A reachability deterministic (#3677)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 14:20:21 +01:00
Asim Aslam b60952cbfe wrapper/x402: settle payments, CDP auth, and conformance fixes (#3676)
The x402 wrapper could advertise a 402 and verify a payment, but never
settled it (the "exact" scheme needs verify + settle to actually move
funds), and its HTTPFacilitator sent no auth, so it could not use the
Coinbase CDP facilitator — the only one that settles Base mainnet. It
also passed the raw base64 X-PAYMENT string where facilitators expect the
decoded payload object.

- Add an optional Settler interface; HTTPFacilitator now implements
  Verify and Settle (POST /verify then /settle), and Require settles a
  verified payment and emits the settlement reference.
- HTTPFacilitator.Authorize hook + x402.CDP(keyID, secret) constructor:
  mint a short-lived Ed25519 Bearer JWT (stdlib crypto only, no chain
  code, no new dependency) so verify/settle authenticate to CDP.
- Decode the X-PAYMENT payload to the object facilitators expect, with
  passthrough for non-JSON payloads.
- Requirements gains extra (EIP-712 domain) and mimeType; the asset and
  its {name,version} are auto-filled for known networks so clients can
  sign. NormalizeNetwork maps base/base-sepolia to CAIP-2 ids.
- Accept the v2 PAYMENT-SIGNATURE request header and emit both
  X-PAYMENT-RESPONSE and PAYMENT-RESPONSE.

Backward compatible: the default network stays "base", the Facilitator
interface is unchanged (Settler is additive), and gateway/mcp builds and
tests unchanged. Adds tests for settle, CDP JWT, header aliases, extra,
and payload decoding.
2026-07-02 14:18:53 +01:00
Asim Aslam c13ff9fc98 docs(priorities): refresh planner queue (#3672)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 13:52:31 +01:00
Asim Aslam 6e010706ec harness: suppress duplicate universe notifications (#3669)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 13:26:43 +01:00
Asim Aslam 75615e81ca docs(priorities): refresh planner queue (#3666)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 13:02:25 +01:00
Asim Aslam 097a4f6f9f harness: make plan delegate side effects idempotent (#3664)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 12:33:08 +01:00
Asim Aslam 898853d948 docs(priorities): refresh planner queue (#3660)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 11:29:28 +01:00
Asim Aslam 573e043cb1 blog: introduce micro loop (#33) (#3659)
A detailed post on `micro loop` — the autonomous loop that builds Go Micro,
now shippable as a command. Covers the mechanism/policy split (Actions as the
runtime, prompt files as editable policy), the five roles, the quickstart and
the two things the CLI can't do (token + branch protection), the load-bearing
decisions and the gotchas we hit (fresh-issue-per-run, bot-comment gating, the
persist-credentials 403), and the dogfood: Go Micro now runs on it. Sequel to
/blog/31 "How Go Micro Builds Itself".


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-02 11:19:09 +01:00
Asim Aslam 76961d503a feat(loop): go-micro runs on micro loop (dogfood its own tool) (#3657)
* feat(loop): go-micro now runs on `micro loop` (dogfood its own tool)

Replace go-micro's five hand-written loop workflows with ones generated by
`micro loop init --roles all`, making "go-micro builds itself with micro loop"
literally true rather than aspirational.

- Generate loop-planner/builder/triage/coherence/release.yml via the CLI with
  go-micro's cadence and wiring (planner :59, builder :29, coherence 07:00,
  release 23:00; CI gate "Harness (E2E)"; token CODEX_TRIGGER_TOKEN; base master;
  tag prefix v). The old loop-architect.yml and loop-devrel.yml become
  loop-planner.yml and loop-coherence.yml.
- Move the queue to .github/loop/PRIORITIES.md and add .github/loop/NORTH_STAR.md
  (a concise steer pointing to internal/docs/THESIS.md), adopting the loop's
  convention.
- Preserve go-micro's rich instructions as editable policy in
  .github/loop/prompts/{planner,builder,triage,coherence}.md — the architect
  founder-lens + adoption steer, the increment builder, harness-failure triage,
  and the DevRel changelog/blog pass — faithfully ported from the old inline
  prompts. Behavior is preserved; only the mechanism is now generated.
- CLI refinement the migration surfaced: prompts (and NORTH_STAR/PRIORITIES) are
  now write-once — `micro loop init --force` refreshes workflow MECHANICS but
  never clobbers customized POLICY. Added renderKeep + a test.
- Update internal/docs/CONTINUOUS_IMPROVEMENT.md (renamed workflows, moved queue,
  the prompt-file model, and a note that these files are generated by micro loop).

Verified: build, go test ./cmd/micro/loop/..., golangci-lint (0 issues), gofmt;
`micro loop verify` passes; all generated workflows are valid YAML; re-running
init --force is idempotent and preserves policy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

* loop: strip prompt editorial comments before posting to the agent

Verification of the migration surfaced that a dispatch workflow posted the
prompt file's leading <!-- editorial --> header to the agent, and __ISSUE__
inside it got substituted too (e.g. "Keep 4242 literal"). Harmless (invisible
in rendered markdown) but unclean and mildly confusing. The dispatch and triage
body construction now strips <!-- --> blocks with `sed '/<!--/,/-->/d'` before
substituting runtime tokens. Regenerated go-micro's workflows; added a test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-02 11:14:21 +01:00
Asim Aslam f839ca7427 feat(cli): prompt-file-driven micro loop + coherence & release roles (#3655)
Rework `micro loop` so the workflows are the mechanism and each dispatch role's
instruction is an editable .github/loop/prompts/<role>.md file (the policy).
That split lets any repo — including go-micro itself — customize behavior by
editing prompt files instead of forking the CLI, which is the prerequisite for
go-micro consuming its own tool without losing its richer prompts.

- Add two opt-in roles: `coherence` (README/docs/CHANGELOG alignment) and
  `release` (cut the next patch tag on new commits; bakes in the
  persist-credentials:false fix so the PAT push isn't clobbered by the
  checkout token — the 403 we hit on the live release action).
- `--roles` selects which roles to scaffold (default planner,builder,triage;
  `all` for everything); `--tag-prefix`, `--release-cron`, `--coherence-cron`
  added. Templates keep the << >> delimiters so GHA ${{ }} passes through;
  prompts leave __ISSUE__/__RUNURL__ as runtime tokens the workflow substitutes.
- `micro loop verify` now checks each present role workflow has its prompt.
- README updated with the five roles and a copy-pasteable flag example
  (folds in the readability fix from the now-closed #3651).

Verified: build, `go test ./cmd/micro/loop/...`, vet, golangci-lint (0 issues),
gofmt; and an end-to-end `micro loop init --roles all` whose generated
workflows all parse as valid YAML and pass `micro loop verify`.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-02 10:47:49 +01:00
Asim Aslam fb1888efb7 docs: connect quickstart to first agent path (#3656)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 10:47:19 +01:00
Asim Aslam 5f3244d09e feat(cli): add micro loop to scaffold a self-improving repo loop (#3649)
`micro loop init` writes an autonomous improvement loop into any repository —
the same planner/builder/triage loop that maintains go-micro, generalized:

- planner (loop-planner.yml): keeps a ranked queue in .github/loop/PRIORITIES.md
- builder (loop-builder.yml): builds the top open item as a single-concern PR,
  auto-merged on green CI
- triage (loop-triage.yml): turns CI failures into scoped fix issues

plus .github/loop/NORTH_STAR.md (direction) and PRIORITIES.md (queue).

The agent adapter is mention-based, not hardcoded to Codex: `--agent @codex`
(or any @mention agent that responds on an issue and can run gh), `--token-secret`,
`--branch`, `--ci-workflow`, and cron flags are the whole config-vs-core boundary.
Templates use << >> delimiters so GitHub Actions' own ${{ }} expressions pass
through untouched. `micro loop verify` checks the wiring and flags the two things
the CLI can't: the token secret and branch protection (the green-CI gate).

Built inside go-micro with the config/core split already drawn, so the workflows
can later be extracted to a standalone reusable-workflows repo without a rewrite.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-02 10:12:12 +01:00
Asim Aslam 1e932321c8 docs(priorities): refresh architect queue (#3650)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 10:11:01 +01:00
Asim Aslam a24eaad1c9 docs: roll changelog for v6.3.12 (#3646)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 09:16:05 +01:00
Asim Aslam 357fdf2777 docs: surface first agent on-ramp (#3644)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 08:52:09 +01:00
Asim Aslam a66fb4ff2c docs(priorities): refresh architect adoption queue (#3641)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 08:16:31 +01:00
Asim Aslam 8e3ba68d58 loop-release: fix 403 on tag push (checkout token clobbered the PAT) (#3638)
goreleaser / goreleaser (push) Waiting to run
* docs: complete Ollama provider surface (capability matrix, README, example fixes)

Follow-up cleanup after merging the Ollama provider (#3636):

- Add the `ollama` row to the AI provider capability matrix in the provider
  guide, and blank-import `ai/ollama` in provider_capabilities_test.go so the
  matrix stays enforced against the registry (the provider registers a stream
  but wasn't imported in that test, so its row went unchecked).
- README: bump "7 LLM providers" → 8 and list Ollama (local + cloud); add its
  default model (`llama3.2`) to the model table.
- Fix a fictional model name shipped in the example and package doc:
  `gemma4:31b-cloud` → `gpt-oss:120b`. gemma4 doesn't exist, and the `-cloud`
  suffix is for cloud models proxied through a local Ollama, not the direct
  ollama.com/v1 endpoint the example uses.
- Record the provider and the new agent.BaseURL/micro.AgentBaseURL option in
  the CHANGELOG [Unreleased] section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

* loop-release: don't let checkout's persisted GITHUB_TOKEN clobber the PAT push

The daily release job computed the next tag correctly but the tag push 403'd:
"Permission to micro/go-micro.git denied to github-actions[bot]" (run
28554612450). Cause: actions/checkout persists the default GITHUB_TOKEN as an
http.extraheader Authorization credential for github.com, which git sends on
ALL requests to that host — including our manual
`git push https://x-access-token:${PAT}@github.com/...`. The persisted header
overrides the URL-embedded PAT, so the push authenticates as
github-actions[bot], which can't push tags (the job only grants
contents: read).

Set persist-credentials: false so no extraheader is written and the PAT in the
push URL is the only credential used.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-02 07:51:31 +01:00
Asim Aslam 6e04f1afb5 docs: complete Ollama provider surface (capability matrix, README, example fixes) (#3637)
Follow-up cleanup after merging the Ollama provider (#3636):

- Add the `ollama` row to the AI provider capability matrix in the provider
  guide, and blank-import `ai/ollama` in provider_capabilities_test.go so the
  matrix stays enforced against the registry (the provider registers a stream
  but wasn't imported in that test, so its row went unchecked).
- README: bump "7 LLM providers" → 8 and list Ollama (local + cloud); add its
  default model (`llama3.2`) to the model table.
- Fix a fictional model name shipped in the example and package doc:
  `gemma4:31b-cloud` → `gpt-oss:120b`. gemma4 doesn't exist, and the `-cloud`
  suffix is for cloud models proxied through a local Ollama, not the direct
  ollama.com/v1 endpoint the example uses.
- Record the provider and the new agent.BaseURL/micro.AgentBaseURL option in
  the CHANGELOG [Unreleased] section.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-02 07:44:58 +01:00
YongSoo Park 110cb44d41 feat: add Ollama provider with local and cloud support (#3636)
Add a dedicated Ollama AI provider (ai/ollama/) that auto-detects
local vs cloud mode based on the base URL:

- Local Ollama: native /api/chat endpoint with NDJSON streaming
- Ollama Cloud: OpenAI-compatible /v1/chat/completions with SSE streaming

Both modes support tool calls with a multi-round execution loop.

Add agent.BaseURL option so agents can point at non-default LLM
endpoints (e.g. local Ollama, proxies). Wire it through micro.AgentBaseURL
at the top level.

Include a complete example (examples/agent-ollama/) demonstrating a
knowledge-base service with auto-discovered tools, a custom time tool,
streaming, and env-var configuration for local vs cloud.

Closes #3632
2026-07-02 07:37:36 +01:00
Asim Aslam f06e7467ce ci: smoke test installer first-run CLI (#3635)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 07:00:45 +01:00
Asim Aslam 412491568f docs(priorities): refresh architect adoption queue (#3631)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 06:28:17 +01:00
Asim Aslam d2e1520a14 Verify zero-to-hero reference app in harness (#3628)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 04:56:41 +01:00
Asim Aslam 0a18ac6c15 docs(priorities): refresh architect queue (#3624)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 04:15:41 +01:00
Asim Aslam 45a23a3417 test first-agent walkthrough boundaries (#3621)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 03:31:30 +01:00
Asim Aslam ae239b0102 docs(priorities): refresh architect queue (#3619)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 02:53:21 +01:00
Asim Aslam 3a2d21f1ac agent: dedupe plan delegate tool side effects (#3616)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 02:35:18 +01:00
Asim Aslam 40a559e8db Harden universe notify finalization (#3612)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 01:08:12 +01:00
Asim Aslam 28320fc1d4 cli: add first-agent preflight diagnostics (#3608)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 00:02:53 +01:00
Asim Aslam cbc8fc62c7 docs(priorities): refresh architect adoption queue (#3605)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 23:39:48 +01:00
Asim Aslam 1f93fa8b1b docs: expose zero-to-hero on-ramp (#3602)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 23:09:17 +01:00
Asim Aslam c6f9940ab2 docs(priorities): refresh architect adoption queue (#3599)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 22:42:02 +01:00
Asim Aslam 9c66455d2a docs: add agent debugging guide (#3596)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 22:13:48 +01:00
Asim Aslam 2215065a2e docs(priorities): refresh architect queue (#3593)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 21:45:21 +01:00
Asim Aslam f8b8b90a3b docs: lead guides nav with hands-on path (#3591)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 21:21:39 +01:00
Asim Aslam 1bcf0e1ae9 docs(priorities): drop completed examples task (#3587)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 20:41:31 +01:00
Asim Aslam b49f5072b1 loop: have DevRel maintain CHANGELOG.md and draft a changelog blog post (#3584)
The DevRel pass now keeps the changelog living instead of letting it drift:
each daily run reconciles a Keep-a-Changelog `[Unreleased]` section against the
PRs that actually merged (user-facing entries only; internal loop/CI churn
skipped) and rolls it into a dated version heading whenever loop-release cuts a
new v6.MINOR.PATCH tag. When enough user-facing work has accumulated (roughly a
week's worth, not a near-empty post every day) it also drafts a "what's new"
changelog blog post narrating what shipped.

Autonomy boundary preserved: CHANGELOG.md upkeep is a safe factual change and
rides the auto-merged DevRel PR; the changelog blog post is opened as its own
PR but left for the human to review/merge, since blog voice stays with the human.

Also fix the CHANGELOG preamble: it claimed calendar versions (YYYY.MM) while
tags are semver (v6.MINOR.PATCH). Correct it, add an `[Unreleased]` section
seeded from real recent work, and note the historical 2026.0x headings.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-01 20:29:53 +01:00
Asim Aslam 63ebe6ab9c docs: surface runnable lifecycle examples (#3585)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 20:25:33 +01:00
Asim Aslam f192c4947c docs(priorities): drop completed install task (#3581)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 19:46:45 +01:00
Asim Aslam 7fd749b475 docs: lead v6 installs with latest (#3579)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 19:24:10 +01:00
Asim Aslam dfe9e436e8 docs(priorities): remove completed first-agent task (#3576)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 18:42:38 +01:00
Asim Aslam 72902ee6fb docs: add your first agent walkthrough (#3574)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 18:27:36 +01:00
Asim Aslam 819403e940 docs(priorities): prioritize adoption on-ramp (#3571)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 17:58:27 +01:00
Asim Aslam 58284c6230 harness: extend live provider deadlines (#3569)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 17:33:29 +01:00
Asim Aslam 66ce68a93f community: add Discord to nav/footer/README/landing; fix stale invite code (#3567)
There was no working, prominent Discord link. Update the stale invite code
(WeMU5AGxD → G8Gk5j3uXr) everywhere it appeared (README, docs, blog, landing,
SECURITY, issue templates, contrib), and add the link prominently: the site
nav and footer includes (so it shows on every landing/docs/blog page), a
Discord badge + a Community line in the README, and a "Join Discord" button on
the landing.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-01 17:01:29 +01:00
Asim Aslam 4bd181fc21 loop: point the architect at adoption + add a daily release action (#3566)
The framework's depth is strong but the on-ramp is the adoption gap, and the
architect queue had filled entirely with internal hardening. Steer the
architect to weight the developer on-ramp/DX (first-agent tutorial,
discoverable examples, docs wayfinding, install friction, debugging) at least
as highly as internal work — a developer succeeding on their first agent
matters more than another conformance/observability increment. Adoption issues
filed: #3561-#3565.

Also add loop-release.yml: a daily patch release that tags v6.MINOR.PATCH+1
when master has new commits (pushed with the PAT so goreleaser fires), so the
installable framework tracks the loop's daily improvements instead of lapsing.
Minor/major bumps stay with the human.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-01 16:38:28 +01:00
Asim Aslam 3143767d78 docs(priorities): refresh architect queue (#3557)
goreleaser / goreleaser (push) Waiting to run
Co-authored-by: Codex <codex@openai.com>
2026-07-01 16:05:37 +01:00
Asim Aslam 666fc4b794 ci+docs: name the loop workflows by role (planner/generator/evaluator) (#3556)
Rename the autonomous-loop workflows so the Actions list maps to the
long-running-agent harness pattern (planner → generator → evaluator):
  architecture-review.yml   -> loop-architect.yml   "Loop: Architect (Planner)"
  continuous-improvement.yml -> loop-builder.yml     "Loop: Builder (Generator)"
  devrel-review.yml          -> loop-devrel.yml      "Loop: DevRel"
  harness-triage.yml         -> loop-triage.yml      "Loop: Triage (Evaluator feedback)"
harness.yml stays the shared Evaluator/CI gate (triage still matches it by the
"Harness (E2E)" name). Document the pipeline + role mapping in
CONTINUOUS_IMPROVEMENT.md, and point to it from CONTRIBUTING so the development
process is discoverable. No behavior change — schedules, gates, and required
checks are unaffected.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-01 15:59:46 +01:00
Asim Aslam 7af73b06d7 ci: auto-triage harness failures into fix issues (hill-climbing loop) (#3549)
Close the loop's feedback path: when the live provider-conformance harness
fails, harness-triage.yml dispatches Codex to triage the failing run — read
logs, root-cause, dedupe against open issues, and file scoped codex/enhancement
issues that the hourly increment loop then fixes and the next harness run
verifies. Transient flakes are ignored; breaking/architectural fixes are
escalated as needs-human rather than auto-built. No human in the middle short
of a genuine judgment call. Documented in CONTINUOUS_IMPROVEMENT.md.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-01 15:51:55 +01:00
Asim Aslam 0811234f3e agent: execute text tool call fallbacks (#3554)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 15:27:14 +01:00
Asim Aslam b64a7d248d docs(priorities): refresh architect queue (#3551)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 14:55:43 +01:00
Asim Aslam e7b08d914f docs(priorities): refresh architect queue (#3545)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 14:03:04 +01:00
Asim Aslam 3a9455f28c harness: label provider conformance phases (#3542)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 12:46:11 +01:00
Asim Aslam 05e53ec38c ci: run the live provider-conformance harness hourly, not daily (#3540)
Match the real-model conformance cadence to the dev/loop velocity so live
regressions and provider drift surface within the hour instead of up to 24h.
The mock harness already runs on every push/PR; this only changes the live
(credentialed) schedule.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-01 12:35:36 +01:00
Asim Aslam dbdd0e1d6f atlascloud: run provider conformance on a stronger (env-selectable) model (#3538)
* atlascloud: env-selectable chat model; run conformance on a stronger model

The daily provider-conformance harness fails 4/5 harnesses on Atlas Cloud —
its default chat model answers agent/tool-use conformance prompts
conversationally instead of performing the task. Atlas is currently the only
provider with a key configured, so the whole live run is red.

Make the Atlas Cloud provider honor an ATLASCLOUD_MODEL env override (falling
back to the existing default), and set it in the harness workflow to a
stronger tool-use model (Qwen3, overridable via an Actions variable). No
change to the default for normal use.

* atlascloud: use minimaxai/minimax-m3 for conformance model

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-01 12:17:12 +01:00
Asim Aslam 8146e29f7e docs(priorities): refresh architect queue (#3539)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 12:10:17 +01:00
Asim Aslam c110774dc7 Add opt-in retries for agent tool calls (#3535)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 10:57:33 +01:00
Asim Aslam c0a5775fb5 docs(priorities): refresh architect queue (#3533)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 10:12:45 +01:00
Asim Aslam f844a23bb2 docs: align public AI harness facts (#3531)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 09:40:28 +01:00
Asim Aslam 04e8759d41 test agent checkpoint resume after restart (#3529)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 09:06:46 +01:00
Asim Aslam 78725135aa docs(priorities): refresh architect queue (#3527)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 08:33:01 +01:00
Asim Aslam 2ff64ff0b2 Document canonical 0-to-hero reference path (#3522)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 07:12:17 +01:00
Asim Aslam 7a8e7cd9ae docs(priorities): advance queue to 0-to-hero reference (#3520)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 06:37:15 +01:00
Asim Aslam b58eed1698 Add retrieval-backed agent memory (#3518)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 04:59:16 +01:00
Asim Aslam c92c9cc244 docs(priorities): refresh architect queue (#3516)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 04:06:59 +01:00
Asim Aslam a2bf43e9ef test: broaden stream provider conformance (#3512)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 03:37:26 +01:00
Asim Aslam 49bac7e4a8 trace scheduled flow dispatch metadata (#3510)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 02:42:31 +01:00
Asim Aslam 5aac7e3ca0 docs(priorities): refresh architect queue after scheduling (#3507)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 01:51:21 +01:00
Asim Aslam d86585bf5c Add scheduled flow agent harness (#3505)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 01:02:29 +01:00
Asim Aslam 88e2b58711 docs(priorities): refresh architect queue (#3503)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 00:38:51 +01:00
Asim Aslam d259383645 Harden agent terminal failure statuses (#3499)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 00:03:21 +01:00
Asim Aslam 2d7ee300a4 docs(priorities): refresh architect queue after conformance (#3497)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 23:40:19 +01:00
Asim Aslam 57fa4e3b7a Add mock provider conformance target (#3495)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 23:09:19 +01:00
Asim Aslam 0f1917f26b docs(priorities): refresh architect queue after verification (#3493)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 22:40:05 +01:00
Asim Aslam 6e9c5e87e9 Add flow step verification loop (#3489)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 22:18:01 +01:00
Asim Aslam d8bb892425 docs(priorities): refresh architect queue after a2a continuity (#3487)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 21:37:56 +01:00
Asim Aslam 064a112c6b a2a: expose resubscribe and input-required support (#3484)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 21:23:43 +01:00
Asim Aslam 24d103c658 docs(priorities): refresh architect queue after memory (#3482)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 20:47:39 +01:00
Asim Aslam 2d8e3da943 blog: expand /blog/32 into a field guide on agent frameworks (#3478)
* blog+docs: drop the word "bet" from the tRPC-Agent-Go comparison

Reword "two bets" / "opposite bet" / "the bet is" to approaches / premise /
principle across blog/32 and the comparison guide.

* blog: expand /blog/32 into a field guide on agent frameworks

Roughly double the length with deeper context: the first wave (LangChain &
co.), the two layers of a harness (intra-agent vs operational), loop
engineering and the move to scheduled/looping/work-performing agents, a
survey of where the frameworks are going (LangGraph, CrewAI, AutoGen, ADK,
tRPC-Agent-Go), then Go Micro's "an agent is a service" position, the honest
tRPC-Agent-Go contrast, and MCP/A2A interop. Drops the word "bet" throughout.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-30 20:20:14 +01:00
Asim Aslam 3081dab246 Add agent memory summarizer hook (#3479)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 20:18:23 +01:00
Asim Aslam fdc422c16e blog+docs: position go-micro vs tRPC-Agent-Go (agent = service) (#3475)
Add a fair, honest positioning piece on the architectural fork with
tRPC-Agent-Go (an agent SDK alongside your services / graph DSL) vs Go Micro
(one runtime where an agent is a service, every endpoint a tool, durable
flows not a graph DSL). New blog post /blog/32 + a parallel section in the
existing comparison guide; honest about where tRPC-Agent-Go is ahead
(eval, self-evolution, RAG) and that they interoperate over MCP/A2A.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-30 19:45:52 +01:00
Asim Aslam 5e3f8db193 docs(priorities): refresh architect queue for memory (#3476)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 19:41:17 +01:00
Asim Aslam f5bf5f7987 Wire A2A streaming through agent StreamAsk (#3471)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 19:18:23 +01:00
Asim Aslam 3ec12b7c72 Trace agent checkpoint resume events (#3468)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 18:22:00 +01:00
Asim Aslam fa4d4b7f6a docs(priorities): refresh architect queue after failure hardening (#3466)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 17:50:23 +01:00
Asim Aslam 43beaafab5 flow: classify workflow failure kinds (#3464)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 17:30:45 +01:00
Asim Aslam 21a20005cb docs(priorities): refresh architect queue (#3462)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 16:51:07 +01:00
Asim Aslam e1b3c587aa Add configurable provider conformance dispatch (#3459)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 16:31:49 +01:00
Asim Aslam da2bbab80c docs(priorities): refresh architect queue (#3457)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 15:55:52 +01:00
Asim Aslam 5a59e1ece4 agent: verify durable resume example (#3452)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 15:26:32 +01:00
Asim Aslam aea03ba9e7 priorities: queue the flow verification/grader loop (#3435) (#3436)
Add the verification loop as a ranked priority — the one missing layer from
the four-loop framing (agent / verification / event-driven / hill-climbing):
flow.Verify + flow.LLMGrader to grade a step's output against a rubric and
retry with feedback. The architect will re-rank on its next pass.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-30 15:17:18 +01:00
Asim Aslam 54bc05e48f docs(priorities): advance agent durability queue (#3450)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 14:40:14 +01:00
Asim Aslam 3ef0d98a16 flow: analyze run traces for optimization (#3447)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 14:25:15 +01:00
Asim Aslam 5a6c8d8b30 docs(priorities): advance architect queue (#3445)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 13:41:58 +01:00
Asim Aslam 1cd918c2b9 flow: add verification grader loop (#3443)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 13:28:53 +01:00
Asim Aslam e96d4a67bc docs(priorities): refresh architect queue (#3441)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 13:09:37 +01:00
Asim Aslam 8994fd03f6 Add A2A fallback provider conformance harness (#3438)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 12:37:40 +01:00
Asim Aslam 0595130f16 run: surface MCP tools in the micro run banner by default (#3434)
The gateway already serves /mcp/tools on :8080 unconditionally (every
endpoint is an AI-callable tool), but the startup banner only printed an MCP
line when --mcp-address was set — so the live `micro run` experience hid the
harness's signature feature even though it was running, and didn't match the
README. Always advertise MCP Tools on the gateway address; keep the optional
standalone MCP-protocol server (--mcp-address) as a clearly separate line.
No behavior change — banner output only.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-30 11:21:26 +01:00
Asim Aslam 37eccc425e docs(priorities): advance provider conformance queue (#3433)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 11:14:39 +01:00
Asim Aslam 010e0fe57c Classify agent run failure summaries (#3430)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 10:55:14 +01:00
Asim Aslam 113f268268 docs: align DevRel public surface facts (#3428)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 09:26:20 +01:00
Asim Aslam c10697f08d docs(priorities): advance resilience queue (#3427)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 09:25:32 +01:00
Asim Aslam f61f3dc04d test: add AI stream provider conformance (#3423)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 08:56:12 +01:00
Asim Aslam d9e6c68938 docs(priorities): refresh architect queue (#3421)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 07:40:48 +01:00
Asim Aslam 8847668c83 Improve agent telemetry error classification (#3418)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 07:06:38 +01:00
Asim Aslam e45bf3d114 docs(priorities): remove completed durable resume item (#3416)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 05:50:51 +01:00
Asim Aslam 98d1f58cd6 Add durable agent resume example (#3414)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 04:56:14 +01:00
Asim Aslam 51158ed2a7 docs(priorities): refresh architect queue (#3412)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 04:04:11 +01:00
Asim Aslam 09bf82d4f6 fix a2a stream fallback (#3410)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 03:26:42 +01:00
Asim Aslam 7d77f31ae2 docs(priorities): advance architect queue (#3408)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 02:47:16 +01:00
Asim Aslam 2dbf70d0ed Guard terminal agent checkpoints on resume (#3406)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 02:34:43 +01:00
Asim Aslam 136c24ad5f docs(priorities): advance architect queue (#3404)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 01:28:16 +01:00
Asim Aslam dc93addb10 ci: consolidate developer flow harness (#3399)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 00:59:24 +01:00
Asim Aslam 09b4b8cc85 docs(priorities): drop shipped reliability item (#3397)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 00:29:11 +01:00
Asim Aslam 7b8139ca3c Harden agent tool execution timeouts (#3395)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 23:55:43 +01:00
Asim Aslam ea6f7f6976 docs(priorities): refresh architect queue (#3393)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 23:32:10 +01:00
Asim Aslam b25a9171c5 ci: fold agent checks into provider conformance (#3389)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 23:08:09 +01:00
Asim Aslam 2fe4eb71d1 docs(priorities): refresh architect queue (#3387)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 22:37:54 +01:00
Asim Aslam c4e110c77f Add deploy dry-run harness checkpoint (#3384)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 22:14:28 +01:00
Asim Aslam e4bb9d6bc7 docs(priorities): refresh architect queue (#3382)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 21:43:53 +01:00
Asim Aslam 562deb0557 docs(examples): maintain zero-to-hero support path (#3379)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 21:24:45 +01:00
Asim Aslam 551beb0858 docs(priorities): remove completed A2A skill item (#3377)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 20:44:39 +01:00
Asim Aslam d831ba2c45 Add typed A2A skill endpoints (#3375)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 20:21:41 +01:00
Asim Aslam a14633b9e8 docs(priorities): remove completed human-input item (#3373)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 19:45:54 +01:00
Asim Aslam 4cc1ebb315 Document canceled human-input resumes (#3371)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 19:24:26 +01:00
Asim Aslam 38ff084756 docs(priorities): refresh architect queue (#3369)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 19:07:01 +01:00
Asim Aslam 585dd789a4 Redact agent run inputs from traces (#3366)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 18:31:20 +01:00
Asim Aslam 971bfdb954 agent: reuse tools instance in setupWithToolHandler (fix StreamAsk tool resolution) (#3365)
goreleaser / goreleaser (push) Waiting to run
2026-06-29 18:24:21 +01:00
Asim Aslam 57873cc5a5 docs(priorities): refresh architect queue (#3363)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 18:04:52 +01:00
Asim Aslam 86bfced452 Wire getting-started harness into make (#3360)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 16:58:29 +01:00
Asim Aslam 8863d27623 events: WithStore option for a durable, replayable stream (#3357)
goreleaser / goreleaser (push) Waiting to run
* events: add WithStore option for a durable stream

* events: honor injected store in NewStream
2026-06-29 15:25:14 +01:00
Asim Aslam efb4b3d191 docs(priorities): refresh architect queue (#3355)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 14:27:42 +01:00
Asim Aslam b83b8d164f Add stream resume for agent checkpoints (#3352)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 13:19:17 +01:00
Asim Aslam 11ff58fec7 docs(priorities): refresh architect queue (#3350)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 12:41:44 +01:00
Asim Aslam f1504507a3 agent: add tool-aware stream ask (#3347)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 11:13:52 +01:00
Asim Aslam a2a7ee17e0 docs(priorities): drop shipped memory item (#3345)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 10:38:20 +01:00
Asim Aslam e704b0e61b docs(priorities): seed streaming-tools (#3341) and multi-skill a2a (#3342) (#3343)
Queue the Next-phase agent streaming item and the a2a multi-skill gap that
block a real consumer from retiring its bespoke planner / A2A handler. The
architecture-review pass re-ranks from here.
2026-06-29 10:26:14 +01:00
Asim Aslam 317bb300ae agent: harden compacted memory resume (#3339)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 09:25:59 +01:00
Asim Aslam ca82b85955 Update architecture priorities after tracing (#3337)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 08:44:29 +01:00
Asim Aslam a99c776880 gateway/mcp: configurable initialize (serverInfo, protocol version) + named not-found (#3335)
goreleaser / goreleaser (push) Waiting to run
* gateway/mcp: configurable initialize + named not-found

NewHandler takes HandlerOptions: WithServerInfo(name, version) and WithProtocolVersion set the initialize response so a product can brand the endpoint instead of advertising go-micro-mcp/2024-11-05. ManualResolver's not-found error now includes the tool name.

* gateway/mcp: configurable initialize + named not-found

NewHandler takes HandlerOptions: WithServerInfo(name, version) and WithProtocolVersion set the initialize response so a product can brand the endpoint instead of advertising go-micro-mcp/2024-11-05. ManualResolver's not-found error now includes the tool name.
2026-06-29 07:22:07 +01:00
Asim Aslam b8ca921ae8 gateway/mcp: richer resolver Call (CallResult/isError, coded errors) + notifications (#3334)
goreleaser / goreleaser (push) Waiting to run
* gateway/mcp: richer resolver Call + spec-correct HTTP handler

Resolver.Call now returns (*CallResult, error): a tool that ran but failed sets CallResult.IsError (returned as a tools/call result with isError:true, per the MCP spec), while a protocol/pre-check failure returns an error -- an *RPCError carries a specific JSON-RPC code. The HTTP handler also answers notifications/* (and id-less requests) with 204 and no body.

* gateway/mcp: richer resolver Call + spec-correct HTTP handler

Resolver.Call now returns (*CallResult, error): a tool that ran but failed sets CallResult.IsError (returned as a tools/call result with isError:true, per the MCP spec), while a protocol/pre-check failure returns an error -- an *RPCError carries a specific JSON-RPC code. The HTTP handler also answers notifications/* (and id-less requests) with 204 and no body.

* gateway/mcp: richer resolver Call + spec-correct HTTP handler

Resolver.Call now returns (*CallResult, error): a tool that ran but failed sets CallResult.IsError (returned as a tools/call result with isError:true, per the MCP spec), while a protocol/pre-check failure returns an error -- an *RPCError carries a specific JSON-RPC code. The HTTP handler also answers notifications/* (and id-less requests) with 204 and no body.
2026-06-29 07:19:39 +01:00
Asim Aslam 159963ab39 agent: trace model attempt metadata (#3333)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 07:16:19 +01:00
Asim Aslam 59397834b4 gateway/mcp: swappable Resolver + mountable JSON-RPC HTTP handler (#3331)
goreleaser / goreleaser (push) Waiting to run
* gateway/mcp: swappable Resolver + mountable JSON-RPC HTTP handler

Adds a Resolver interface (Manual/Registry) so the tool source is
pluggable, and NewHandler(resolver) serving standard MCP JSON-RPC over
HTTP as an http.Handler you can mount on your own server. Neither
resolver exposes the internal store/broker tools. Additive — the
existing Serve() path is unchanged.

* gateway/mcp: swappable Resolver + mountable JSON-RPC HTTP handler

Adds a Resolver interface (Manual/Registry) so the tool source is
pluggable, and NewHandler(resolver) serving standard MCP JSON-RPC over
HTTP as an http.Handler you can mount on your own server. Neither
resolver exposes the internal store/broker tools. Additive — the
existing Serve() path is unchanged.

* gateway/mcp: swappable Resolver + mountable JSON-RPC HTTP handler

Adds a Resolver interface (Manual/Registry) so the tool source is
pluggable, and NewHandler(resolver) serving standard MCP JSON-RPC over
HTTP as an http.Handler you can mount on your own server. Neither
resolver exposes the internal store/broker tools. Additive — the
existing Serve() path is unchanged.
2026-06-29 07:03:23 +01:00
Asim Aslam ee996afd39 Update architecture priorities after streaming (#3330)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 06:36:13 +01:00
Asim Aslam 236c5851c2 Add OpenAI-compatible streaming providers (#3327)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 05:01:51 +01:00
Asim Aslam 5ee3a5224f ai/atlascloud: replace stale Stream_NotImplemented test (#3324)
goreleaser / goreleaser (push) Waiting to run
Streaming is implemented now (v6.3.3); the old test expected
ErrStreamingUnsupported and failed. Replace it with a real SSE streaming
test (httptest) that also asserts stream_options.include_usage and the
final usage chunk.
2026-06-29 03:36:39 +01:00
Asim Aslam c9f1ac921f Update architect priorities after agent resume (#3322)
Harness (E2E) / Harnesses (mock LLM) (push) Waiting to run
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Waiting to run
Lint / golangci-lint (push) Waiting to run
Run Tests / Unit Tests (push) Waiting to run
Run Tests / Etcd Integration Tests (push) Waiting to run
Co-authored-by: Codex <codex@openai.com>
2026-06-29 02:49:27 +01:00
Asim Aslam c395010565 Expose durable agent resume APIs (#3319)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 02:36:07 +01:00
Asim Aslam dca263f301 Update architect priorities after CLI inspection (#3317)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 01:27:51 +01:00
Asim Aslam 3540bf5a50 Add CLI inspect command for run history (#3313)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 00:57:40 +01:00
Asim Aslam 0d86fd5bcd Update architect priorities after failure hardening (#3311)
Co-authored-by: Codex <codex@openai.com>
2026-06-29 00:33:17 +01:00
Asim Aslam 205d6a55a3 Respect provider retry-after backoff hints (#3309)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 23:57:17 +01:00
Asim Aslam 7588a427cc Update architect priorities (#3307)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 23:28:55 +01:00
Asim Aslam 7b9937068d ai: surface token usage on streaming responses (#3304)
goreleaser / goreleaser (push) Waiting to run
* ai/atlascloud: surface token usage on streams

Request stream_options.include_usage and return the final usage chunk
as a Response with Usage set, so streaming callers can record usage.

* ai/openai: surface token usage on streams

Request stream_options.include_usage and return the final usage chunk
as a Response with Usage set, so streaming callers can record usage.
2026-06-28 23:13:25 +01:00
Asim Aslam f8d8cb39a7 Document provider conformance scheduling (#3303)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 22:55:49 +01:00
Asim Aslam 87a0c0c93b ai/atlascloud: implement streaming (#3301)
goreleaser / goreleaser (push) Waiting to run
Atlas exposes an OpenAI-compatible SSE chat endpoint; emit content
deltas as they arrive instead of returning ErrStreamingUnsupported.
2026-06-28 22:42:03 +01:00
Copilot f31a7acf0b fix: gofmt formatting in ai/anthropic/anthropic.go (#3299)
* Initial plan

* fix: remove extra blank line in ai/anthropic/anthropic.go (gofmt)

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-28 22:38:32 +01:00
Asim Aslam 4279a5eed3 ai: add WithMaxTokens option (#3300)
goreleaser / goreleaser (push) Waiting to run
* ai: add WithMaxTokens option

Let callers cap response length; providers send max_tokens when set
(anthropic keeps its 8192 default otherwise).

* ai: add WithMaxTokens option

Let callers cap response length; providers send max_tokens when set
(anthropic keeps its 8192 default otherwise).

* ai: add WithMaxTokens option

Let callers cap response length; providers send max_tokens when set
(anthropic keeps its 8192 default otherwise).

* ai: add WithMaxTokens option

Let callers cap response length; providers send max_tokens when set
(anthropic keeps its 8192 default otherwise).
2026-06-28 22:37:50 +01:00
Asim Aslam c39a846d3b Update architecture priorities (#3298)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 22:31:33 +01:00
Asim Aslam b707c7e305 ai: thread Request.Messages into provider requests (#3293)
goreleaser / goreleaser (push) Waiting to run
* ai/atlascloud: thread Request.Messages into the request

Fold conversation history (req.Messages) between the system prompt and
the final user prompt so multi-turn context reaches the model.

Refs #3292

* ai/openai: thread Request.Messages into the request

Fold conversation history (req.Messages) between the system prompt and
the final user prompt so multi-turn context reaches the model.

Refs #3292

* ai/anthropic: thread Request.Messages into the request

Fold conversation history (req.Messages) between the system prompt and
the final user prompt so multi-turn context reaches the model.

Refs #3292
2026-06-28 22:19:59 +01:00
Asim Aslam 5bfc37708d Add support example smoke test (#3291)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 21:58:56 +01:00
Asim Aslam 4d852c5e36 Update architect priorities after deploy contract (#3289)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 21:32:15 +01:00
Asim Aslam f7a3e8461e test deploy inner-loop contract (#3287)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 21:07:20 +01:00
Asim Aslam 87eb540a04 Update architect priorities for DX gaps (#3285)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 20:35:29 +01:00
Asim Aslam 48e05385c7 Add agent human input pause resume (#3281)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 20:04:25 +01:00
Asim Aslam cb10149f54 Update architect priorities after memory work (#3279)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 19:24:54 +01:00
Asim Aslam 66438697b3 Improve compacting memory recall ranking (#3276)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 19:03:39 +01:00
Asim Aslam 6e1c11ca74 Update architect priorities after A2A handoffs (#3274)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 18:33:59 +01:00
Asim Aslam 44c08a6bd2 Add A2A resubscribe and input-required handoffs (#3271)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 17:59:49 +01:00
Asim Aslam 9c01202599 Update architect priorities after OTel spans (#3269)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 17:32:36 +01:00
Asim Aslam b4cfe51115 Emit agent run events on OTel spans (#3267)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 17:04:26 +01:00
Asim Aslam 2221834707 Update architect priorities after streaming conformance (#3265)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 16:33:06 +01:00
Asim Aslam 5813f53117 test ai streaming conformance (#3263)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 16:17:23 +01:00
Asim Aslam 72e6161b02 Update architect priorities after agent checkpointing (#3261)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 15:41:44 +01:00
Asim Aslam 1789819c39 Add agent tool checkpoint replay protection (#3259)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 15:22:11 +01:00
Asim Aslam 8676dcacf2 Update architect priority queue after 0-to-hero (#3257)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 14:38:08 +01:00
Asim Aslam 22b3b9ca22 Add zero-to-hero CI reference scenario (#3252)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 14:09:17 +01:00
Asim Aslam 4ca8948ba4 Update architect priorities after 0-to-1 verification (#3250)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 13:34:18 +01:00
Asim Aslam 28cbf0be7e test: verify scaffolded service run and call contract (#3248)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 13:14:22 +01:00
Asim Aslam e8e6cb1210 Update architect priorities after conformance (#3246)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 12:40:00 +01:00
Asim Aslam 2a06a043bd ci: run live agent provider conformance (#3244)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 12:23:04 +01:00
Asim Aslam 6eef58f815 Update architect priorities (#3242)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 11:13:36 +01:00
Asim Aslam 6da799d64f Classify AI retry failures (#3238)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 10:42:37 +01:00
Asim Aslam 220b370641 Update architect priorities (#3236)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 10:08:20 +01:00
Asim Aslam ac2163ef49 docs: align public TLS and A2A status (#3231)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 09:20:26 +01:00
Asim Aslam 9d6814f5ae docs: refresh roadmap after A2A task support (#3229)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 08:55:36 +01:00
Asim Aslam 929cd1ab1d Add A2A task continuation and push callbacks (#3226)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 07:09:03 +01:00
Asim Aslam fe651b4d01 Update architect priorities after x402 (#3224)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 06:35:10 +01:00
Asim Aslam 05ee980bf5 Harden x402 spend caps (#3222)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 04:54:15 +01:00
Asim Aslam e1d19c4cb5 Update architect priorities after memory and HITL (#3220)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 04:08:37 +01:00
Asim Aslam 61f873174c Add durable approval pause for agents (#3218)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 03:37:50 +01:00
Asim Aslam 71581e9a61 Add agent memory compaction controls (#3216)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 02:39:06 +01:00
Asim Aslam 892b4f185d Update architect priority queue after lifecycle hooks (#3213)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 01:30:09 +01:00
Asim Aslam 30f53e42a7 Expose model retry attempt metadata (#3207)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 00:56:21 +01:00
Asim Aslam 016eb82a32 Update architect priorities after streaming (#3205)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 00:29:03 +01:00
Asim Aslam a576f65b08 Improve A2A streaming task lifecycle (#3203)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 23:57:24 +01:00
Asim Aslam 07f0d30aea Update architect priority queue after observability (#3201)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 23:26:33 +01:00
Asim Aslam 3db9617f43 Add agent trace provider helper (#3198)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 23:02:09 +01:00
Asim Aslam c19e4829ba Update architect priority queue (#3196)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 22:28:28 +01:00
Asim Aslam f37ed42bf3 Add registry service readiness check (#3194)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 21:56:22 +01:00
Asim Aslam 114cd7e19e Update architect priorities after conformance (#3192)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 21:30:21 +01:00
Asim Aslam 28fc2ea340 Add agent provider conformance matrix (#3190)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 21:08:04 +01:00
Asim Aslam 38f5c13e65 Update architect priority queue (#3188)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 20:37:32 +01:00
Asim Aslam 27f5e2be52 Add OpenAI streaming path (#3185)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 20:01:45 +01:00
Asim Aslam 1fa7584be8 Update architect priority queue (#3183)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 19:34:11 +01:00
Asim Aslam e8c632456a Add checkpointed agent run resume (#3180)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 19:00:18 +01:00
Asim Aslam 9a410cbe8b Update architect priority queue (#3176)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 17:46:45 +01:00
Asim Aslam 620374ef22 Add zero-to-hero harness test contract (#3174)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 17:08:04 +01:00
Asim Aslam 31556b310e loop: continuous architect + lead the North Star with the mission/canon (#3172)
* ci: run the architect continuously as the founder lens

Make the architect a continuous overseer (hourly at :59, just before the :29
increment) instead of an every-few-days check-in. Each run it tracks live state
(what merged, what's in flight), keeps the roadmap priorities live, and judges
cohesion across harness/framework/dev-UX plus missing pieces and realignment —
re-ranking internal/docs/PRIORITIES.md to match reality. It only opens a PR when
the ranking actually changes (otherwise it just posts an assessment and closes
its issue), to avoid churn.

* thesis: lead the North Star with the mission, grounded in the canon

Instill the years of context the loop was missing: the vision lives in the
corpus (blog, README, website), not a single doc. Lead THESIS.md with an
explicit Mission — "what problem we solve" (make building an agent as easy as
building a service, on one runtime, because an agent is a distributed system),
distilled from the corpus — and name the blog/README/website as the canon the
North Star is a distillation of and must stay faithful to. Wire the architect to
judge every priority against the mission and re-derive alignment from the canon,
flagging drift in either direction (work vs mission, or thesis vs the blog).

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-27 16:58:24 +01:00
Asim Aslam 0e67971c53 ci: couple architect and increment loop through a ranked priority queue (#3171)
The architect now prioritizes the roadmap + an internal scan into a single
ranked, issue-linked queue in internal/docs/PRIORITIES.md (re-ranked each run),
and the hourly increment loop works the top open item from that queue instead
of independently guessing each hour — falling back to its own judgment only
when the queue is empty. So work is roadmap-driven by default and the human can
redirect by reordering the file or its issues.

- New internal/docs/PRIORITIES.md (seeded from the roadmap + open issues).
- architecture-review.yml: architect owns/re-ranks PRIORITIES.md and keeps each
  top item backed by a scoped issue.
- continuous-improvement.yml: pick the top open queue item; close both the
  priority issue and the run tracker.
- CONTINUOUS_IMPROVEMENT.md: document the architect → queue → increment pipeline.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-27 16:18:03 +01:00
Asim Aslam 2af5e6ae8f Record A2A agent replies in task history (#3170)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 16:09:30 +01:00
Asim Aslam 656c4ea1e7 flow: tag single-step run info as flow (#3168)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 15:16:38 +01:00
Asim Aslam 4a09b4469a Add flow execution timeout option (#3164)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 14:04:58 +01:00
Alexander Serheyev 25d66f737d fix: retract phantom modules for cmd paths (#3154)
* fix: retract phantom modules for cmd and cmd/micro

* fix: remove temporary go.mod files to restore build
2026-06-27 13:19:53 +01:00
Asim Aslam 45d68c932e Track parent run IDs for durable flows (#3162)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 13:07:18 +01:00
Asim Aslam c5c08e24d8 test: cover micro new no-mcp contract (#3160)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 12:17:19 +01:00
Asim Aslam 8a0f4a7636 Add flow step context to run info (#3158)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 11:22:23 +01:00
Asim Aslam bcf2c92931 Add A2A completed-task streaming (#3156)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 10:29:01 +01:00
Asim Aslam 57ca94a88b docs: align public x402 flag examples (#3153)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 09:08:38 +01:00
Asim Aslam ba231efe71 Update README.md 2026-06-27 07:08:08 +01:00
Asim Aslam f755eadd47 Propagate flow checkpoint failures (#3150)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 06:55:56 +01:00
Asim Aslam 467c937873 Improve flow run history validation (#3148)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 04:55:49 +01:00
Asim Aslam 3f45771840 Publish provider conformance summaries (#3146)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 03:29:12 +01:00
Asim Aslam c9e045961d Add flow run stage filtering (#3144)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 02:28:39 +01:00
Asim Aslam 590aacb518 docs: keep AI provider capability matrix verified (#3142)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 00:58:39 +01:00
Asim Aslam 87eb5ce665 Add filters for flow run history (#3140)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 00:00:58 +01:00
Asim Aslam ed904aa6ad Propagate flow run lineage to agent chats (#3138)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 23:04:18 +01:00
Asim Aslam 59d028b8e1 test ai retry policy semantics (#3136)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 22:16:59 +01:00
Asim Aslam 19818ad638 Expose streaming in provider capabilities (#3134)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 21:18:38 +01:00
Asim Aslam 993ce668b2 Add provider capability markdown export (#3132)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 20:20:08 +01:00
Asim Aslam 1d412fa6f5 Trigger test workflow on PR updates (#3130)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 19:14:39 +01:00
Asim Aslam 271c27c44f Validate durable flow step names (#3128)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 18:18:06 +01:00
Asim Aslam cefc9438c4 Add JSON output for AI provider capabilities (#3126)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 17:31:18 +01:00
Asim Aslam af39aac297 Add provider conformance JSON summary (#3124)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 16:31:25 +01:00
Asim Aslam 41717aaa9a ci: run 0-to-1 scaffold contract in harness (#3122)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 15:26:25 +01:00
Asim Aslam 5d1137d8f9 docs: add provider conformance matrix (#3106)
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-26 14:30:51 +01:00
Asim Aslam a14b3e79e2 docs: add provider conformance matrix (#3120)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 14:23:56 +01:00
Asim Aslam 130022e544 Record full agent timelines without tracing (#3118)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 13:27:08 +01:00
Asim Aslam 0d3f3f2be5 Record agent run timelines without tracing (#3116)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 12:35:03 +01:00
Asim Aslam ae91863343 Add trace filter for agent runs (#3114)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 10:50:49 +01:00
Asim Aslam 72f8067e57 docs: finish constructor example cleanup (#3112)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 09:30:58 +01:00
Asim Aslam 8533306e64 docs: align service constructor examples (#3111)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 09:29:36 +01:00
Asim Aslam 384987da9f docs: align service constructor examples (#3110)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 09:26:40 +01:00
Asim Aslam ddde9472fc Expose agent chat run identifiers (#3108)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 08:53:33 +01:00
Asim Aslam d7a74735c8 Add AI provider capability command (#3104)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 04:55:16 +01:00
Asim Aslam 581b2120a3 Verify zero-to-hero harness via workflow (#3102)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 03:29:00 +01:00
Asim Aslam 871af7e6d3 Add pending filter for flow run history (#3100)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 02:35:50 +01:00
Asim Aslam 9d39ed3292 Add OpenTelemetry spans for stepped flows (#3098)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 01:10:11 +01:00
Asim Aslam d8f711431f agent: stop canceled tool execution (#3096)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 00:07:26 +01:00
Asim Aslam 5d22cd4a5b Correlate agent trace spans (#3094)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 23:09:39 +01:00
Asim Aslam f1f987cb8d provider conformance: print capability matrix (#3092)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 22:18:34 +01:00
Asim Aslam 7b9c583dcf blog: "How Go Micro Builds Itself" — the autonomous Codex loop (#3090)
A post on how the framework is increasingly built by an autonomous loop of
two AI agents (Codex implementing scoped increments, Claude Code
orchestrating, the human setting direction): the dispatch→build→auto-merge
mechanism, the three altitudes (architect/increment/DevRel), the failure
modes we hit wiring it up, and the concrete increments it produces.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 21:59:00 +01:00
Asim Aslam b7f1ac6d6d Add flow run correlation to step contexts (#3089)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 21:20:38 +01:00
Asim Aslam fb14ccbc17 Add deterministic AI capability rows (#3087)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 20:21:00 +01:00
Asim Aslam 5f40cae7af ci: add DevRel + Architect overseer passes to the loop (#3085)
The hourly loop ships increments but nothing watches the whole. Add two
periodic high-altitude passes, same dispatch mechanism (fresh issue → @codex):

- devrel-review.yml (daily): audits README, website, docs, blog for coherence
  with the North Star, README crispness, and blog-worthy material. Safe
  alignment/crispness fixes auto-merge; brand/positioning copy and blog drafts
  are surfaced in a report for the human, never auto-merged.
- architecture-review.yml (every ~3 days): reviews the framework/harness against
  the thesis and files scoped follow-up issues that feed the increment loop. It
  does not make breaking/architectural changes itself.

Documented both in CONTINUOUS_IMPROVEMENT.md (Overseer passes).


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 19:42:58 +01:00
Asim Aslam 496d5b7644 Show parent agent runs in CLI history (#3084)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 19:28:32 +01:00
Asim Aslam 44e62f5e8b flow: honor canceled checkpoint contexts (#3082)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 18:24:35 +01:00
Asim Aslam 18ae00dad0 Harden agent harness contracts (#3080)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 17:36:36 +01:00
Asim Aslam 6f9adfd375 ai: expose unsupported streaming sentinel (#3078)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 16:38:14 +01:00
Asim Aslam 8fc4f58a17 Add AI provider capability matrix (#3076)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 15:26:49 +01:00
Asim Aslam 19ad28e04b Propagate A2A request cancellation (#3074)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 14:32:06 +01:00
Asim Aslam 319ff1972b ci: require live provider conformance keys (#3072)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 13:28:57 +01:00
Asim Aslam 03791759ef Add filtered agent run summaries (#3070)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 12:35:39 +01:00
Asim Aslam 5e3cef7cd9 Add agent run summary status and duration (#3068)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 10:50:49 +01:00
Asim Aslam 751530aaec website: refresh Developer Experience copy for the harness DX (#3066)
The section only mentioned micro run (services) and micro deploy. Update it
to the current lifecycle DX: micro new scaffolds a service or agent (every
endpoint an MCP tool), micro run starts everything with hot reload + gateway
+ console, and micro chat talks to agents and services. Drops micro deploy.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 10:33:34 +01:00
Asim Aslam 3a9f45750b docs + website: loop mechanics doc; landing "Features" + subtitle trim (#3065)
* ci: self-merge Codex PRs via native auto-merge; retire the sweep

With branch protection + "Allow auto-merge" now enabled on master, Codex
enables GitHub auto-merge on its own PR (gh pr merge --squash --auto) right
after opening it, so the PR lands the moment the required CI checks pass —
no polling sweep, and the green-CI gate is enforced by GitHub instead of by
gh pr checks in a cron. Removes auto-merge-codex.yml and updates the dispatch
and AGENTS.md accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

* docs: document the durable loop mechanics (stub→gh, branch, auto-merge)

Capture the hard-won wiring of the autonomous loop so it isn't re-derived:
fresh issue per increment, user-PAT dispatch (Codex ignores the bot), Codex
opening the PR via gh (make_pr is a no-op stub), unique codex/ branch + label,
and native auto-merge gated by branch protection with 0 approvals. Adds a
"do-not-break" list (don't re-add approvals, don't reuse one tracker issue,
don't use make_pr, don't re-implement during the summary→PR lag).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

* website: rename section to "Features"; trim subtitle

Rename the feature-grid heading from "The Runtime Around the Agent" to
"Features", and drop "once they leave the demo." from the subtitle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 10:22:51 +01:00
Asim Aslam 2def581408 ci: self-merge Codex PRs via native auto-merge; retire the sweep (#3064)
With branch protection + "Allow auto-merge" now enabled on master, Codex
enables GitHub auto-merge on its own PR (gh pr merge --squash --auto) right
after opening it, so the PR lands the moment the required CI checks pass —
no polling sweep, and the green-CI gate is enforced by GitHub instead of by
gh pr checks in a cron. Removes auto-merge-codex.yml and updates the dispatch
and AGENTS.md accordingly.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 10:13:24 +01:00
Asim Aslam 95ee402c8b ci: pin Codex PRs to a unique codex/ branch + codex label (#3063)
Codex was pushing to a generic branch (e.g. "work"), which the auto-merge
sweep ignores (it only matches codex/* branches with the codex label) and
which collides across runs. The dispatch and AGENTS.md now instruct Codex to
create a unique codex/increment-<issue> branch and pass --label codex to
gh pr create, so every increment is isolated and actually swept.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 09:48:25 +01:00
Asim Aslam 8aeba56839 Harden provider conformance selection (#3061)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 09:47:55 +01:00
Asim Aslam 42fdad439c docs: AGENTS.md — open PRs via gh, not the make_pr stub (#3062)
The initial AGENTS.md instructed Codex tasks to use the make_pr tool and
said a shell GitHub token "is not a substitute" — but make_pr in the Codex
sandbox is a no-op stub that only records metadata and never opens a PR, so
that guidance steers every run back into the broken path. Replace it with
the working flow: push the branch and open the PR with the gh CLI
(git push + gh pr create), which is what actually creates PRs now.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 09:32:50 +01:00
257 changed files with 23663 additions and 1347 deletions
+1 -1
View File
@@ -48,4 +48,4 @@ Add any other context about the problem here.
- [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)
- [Discord Community](https://discord.gg/G8Gk5j3uXr)
+3
View File
@@ -1,5 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: 🔒 Report a vulnerability
url: https://github.com/micro/go-micro/security/advisories/new
about: Privately disclose security vulnerabilities to the maintainers.
- name: 💖 Sponsor Go Micro
url: https://github.com/sponsors/asim
about: Fund ongoing development and see your name or logo on the project.
+1 -1
View File
@@ -39,4 +39,4 @@ Add any other context, code examples, or screenshots about the feature request h
- [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)
- [Discord Community](https://discord.gg/G8Gk5j3uXr)
+32
View File
@@ -0,0 +1,32 @@
# North Star
The direction the loop aligns every increment to. Depth lives in
[`internal/docs/THESIS.md`](../../internal/docs/THESIS.md); this is the short,
operative version the planner and builder read each run.
## Mission
Make building an **agent** as easy as building a **service**, on one runtime.
Go Micro is a holistic agent harness and service framework encapsulating the
lifecycle of **services → agents → workflows** — pluggable, progressive, and
AI-native by default.
## Right now — developer adoption
The framework's depth is strong; the **on-ramp** is the gap. Weight the developer
experience — a walkable first-agent tutorial, discoverable examples, docs
wayfinding, install friction, debugging, the 0→1 and 0→hero path — **at least as
highly as internal hardening**. A developer succeeding on their first agent
matters more right now than another conformance/observability/interop increment.
Do not let the queue fill entirely with internal depth work.
## Guardrails
- One concern per PR; small and reversible.
- The gate is green CI (`go build`, `go test`, `golangci-lint`, `make harness`),
not human review — keep the suite strong; the loop is only as good as its evaluator.
- **Off-limits without a human** (surface as notes, never auto-merge): breaking
public-API changes, brand/positioning/marketing copy, new dependencies,
architectural rewrites, product-default changes with broad behavioral impact.
- Stay on `claude/*` / `codex/*` branches; base PRs on `master`. See
[`CODEX.md`](../../CODEX.md) and [`internal/docs/CONTINUOUS_IMPROVEMENT.md`](../../internal/docs/CONTINUOUS_IMPROVEMENT.md).
+28
View File
@@ -0,0 +1,28 @@
# Priorities
The ranked work queue for the autonomous improvement loop. The
**architecture-review** pass (the *architect*) owns this file: each run it turns
the [roadmap](../../ROADMAP.md) plus an internal scan (gaps in the
services → agents → workflows lifecycle, API coherence, drift, tech debt, test and
DX friction) into a single ordered list — highest-value first — and links each
item to a tracking issue. The hourly **continuous-improvement** pass works the
**top item whose issue is still open**. So the architect decides *what*, and the
increment loop *builds* it.
**Reading / editing.** An item is done when its linked issue closes (the increment
that builds it adds `Closes #<issue>`). Roadmap phase (Now → Next → Later) is the
primary ordering; internal findings are interleaved by value, not kept in a
separate list. The human can reorder this list — or the issues — at any time to
redirect the loop; direction always wins.
**Off-limits to the loop** (the architect proposes these as notes, never as queue
items the loop can auto-merge): brand/positioning copy, breaking public-API
changes, architectural rewrites. Those go to the human.
## Work queue (ranked)
1. **Harden AtlasCloud guarded-delegate conformance** ([#4183](https://github.com/micro/go-micro/issues/4183)) — #4185 closed the duplicate delegated notification gap from #4163, and the latest live provider run now fails one step earlier in the same Now-phase adoption seam: AtlasCloud completes the required echo tool call but does not reliably exercise the refused guarded `delegate` path. Make the conformance prompt/retry/fallback path require both tool calls before accepting a final answer so the scheduled evaluator reflects real services → agents → workflows behavior instead of a provider-specific shortcut.
2. **Propagate cancellation and retry signals through provider model calls** ([#4175](https://github.com/micro/go-micro/issues/4175)) — once the live conformance harness again proves the guarded delegate path, the next Now-phase reliability gap is failure handling under real provider conditions: cancellation/deadline propagation and retry/backoff must not duplicate tool side effects. This keeps the same services → agents → workflows lifecycle dependable across providers without changing public APIs.
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
architecture-review pass._
+14
View File
@@ -0,0 +1,14 @@
<!--
The BUILDER prompt — go-micro's continuous-improvement increment. Editable
policy; the workflow prepends the agent @mention and substitutes __ISSUE__
before posting. Keep __ISSUE__ literal.
-->
Run one continuous-improvement increment per `internal/docs/CONTINUOUS_IMPROVEMENT.md`, aligned to the North Star in `.github/loop/NORTH_STAR.md` (the services → agents → workflows lifecycle, with developer adoption as the current goal).
PICK THE WORK FROM THE QUEUE: read `.github/loop/PRIORITIES.md` and take the highest-ranked item whose linked issue is still OPEN — that is your task, and its issue number is the one you close. If `PRIORITIES.md` is missing or every listed item's issue is already closed, fall back to the single highest-value roadmap / open-issue / improvement-radar item yourself.
Implement it, and VERIFY `go build ./...`, `go test ./...`, and `golangci-lint run ./...`.
Open the PR YOURSELF from the shell — do NOT use the make_pr tool (in this environment it only records metadata and never creates a PR). Create a uniquely-named branch under the `codex/` prefix: `git switch -c codex/increment-__ISSUE__`, then `git push -u origin codex/increment-__ISSUE__`, then `gh pr create --base master --label codex --title "<title>" --body "<body; include 'Closes #<the priority issue you built>' so it leaves the queue, and 'Closes #__ISSUE__' for this run's tracker>"`. Finally enable auto-merge so GitHub merges it once CI is green: `gh pr merge --squash --auto --delete-branch`.
One concern per PR. Stay out of breaking public API and brand/positioning copy — surface those as notes for the human instead.
+14
View File
@@ -0,0 +1,14 @@
<!--
The COHERENCE prompt — go-micro's DevRel pass (public-surface coherence +
CHANGELOG upkeep + changelog blog). Editable policy; the workflow prepends the
agent @mention and substitutes __ISSUE__ before posting. Keep __ISSUE__ literal.
-->
Act as DevRel for go-micro. Do these, in order.
COHERENCE AUDIT. Audit the public surface — `README.md`, `internal/website/` (landing `index.html` + `docs/`), and the blog under `internal/website/blog/` — for coherence with the North Star in `.github/loop/NORTH_STAR.md` (an agent harness and service framework; the services → agents → workflows lifecycle). Look for: places where README / website / docs contradict each other, are stale, or describe behavior that has since changed (cross-check against the code and recently merged PRs); whether the README is crisp and leads with the harness positioning; and one to three genuinely blog-worthy items from recently shipped work.
CHANGELOG UPKEEP (safe factual task — goes in the auto-merged PR). Keep `CHANGELOG.md` living, in Keep-a-Changelog format with newest content at the top under `## [Unreleased]`. Enumerate PRs merged to master since the last update (`gh pr list --state merged --base master --limit 60 --json number,title,mergedAt,labels`) and add a concise, user-facing entry for each genuine change not yet recorded under the right `### Added` / `### Changed` / `### Fixed` / `### Documentation` subheading — SKIP internal loop/CI/priorities-refresh churn. If a new `vX.Y.Z` tag was cut since the last run (`git fetch --tags --force`), rename `## [Unreleased]` to `## [X.Y.Z] - <Month YYYY>` and open a fresh empty `## [Unreleased]` above it. Do not invent entries.
CHANGELOG BLOG POST (blog voice — do NOT auto-merge). If, and only if, enough user-facing work has accumulated since the last changelog post to be worth reading (roughly a week's worth; not a near-empty post every day), draft a short "What's new in Go Micro" post as the next-numbered file in `internal/website/blog/`, mirroring the latest post's frontmatter and prev-nav, and add an entry at the top of `internal/website/blog/index.html`. Base it strictly on the CHANGELOG.
THEN: (A) post a findings report as a comment on this issue (#__ISSUE__) — what's aligned, what drifted, what you fixed, the CHANGELOG entries added, and whether you drafted a blog post (and why/why not). (B) Open ONE auto-merging PR for the SAFE factual work only — coherence/crispness fixes AND the CHANGELOG update (NOT brand/positioning rewrites, NOT the blog post): `git switch -c codex/coherence-__ISSUE__`, `git push -u origin codex/coherence-__ISSUE__`, `gh pr create --base master --label codex --title "<title>" --body "<summary, Closes #__ISSUE__>"`, then `gh pr merge --squash --auto --delete-branch`. (C) If you drafted a changelog blog post, open it as a SEPARATE PR (`codex/coherence-blog-__ISSUE__`, title prefixed `blog:`) and do NOT enable auto-merge — leave it for the human. Same for any brand/positioning copy. Do not use the make_pr tool.
+16
View File
@@ -0,0 +1,16 @@
<!--
The PLANNER prompt — go-micro's "architect / founder lens". Editable policy;
the workflow prepends the agent @mention and substitutes __ISSUE__ (this run's
tracking issue) before posting. Keep __ISSUE__ literal.
-->
Act as the architect — the founder lens — for go-micro, running continuously alongside the builders. Hold the whole picture: how the harness, the framework, and the developer UX fit together, what is in flight and what just merged, what to prioritize next, and what is missing or has drifted.
(1) TRACK STATE — scan recently merged PRs and open `codex` PRs/issues to see what shipped and what is being built right now, so the queue reflects reality (drop done items, don't re-queue in-flight work).
(2) ASSESS against the North Star in `.github/loop/NORTH_STAR.md` — lead with its Mission (*make building an agent as easy as building a service, on one runtime*) and re-derive alignment from the CANON: the blog under `internal/website/blog`, the `README`, and the website (read these, don't rely on the North Star alone), then `ROADMAP.md` (Now → Next → Later). Judge every priority against the mission: does it make the services → agents → workflows lifecycle simpler, more cohesive, and more operable? CURRENT GOAL — developer adoption: weight the on-ramp (walkable first-agent tutorial, discoverable examples, docs wayfinding, install friction, debugging, 0→1 and 0→hero) at least as highly as internal hardening; do not let the queue fill entirely with internal depth work. Look at coherence and seams across the core packages (agent, ai, flow, gateway/mcp, gateway/a2a, model, server, store, registry) and the dev inner loop (scaffold → run → chat → inspect → deploy). Flag drift in either direction: work drifting from the mission, or the North Star/website drifting from the lived story in the blog.
(3) MAINTAIN THE QUEUE in `.github/loop/PRIORITIES.md` — a SINGLE ordered list, highest-value first, each item linking a scoped, CI-verifiable issue (#N); roadmap phase is the primary ordering, internal findings (cohesion gaps, DX friction, missing pieces) interleaved by value. For any prioritized gap with no issue, file one: `gh issue create --label codex --label enhancement --title "<scoped task>" --body "<goal, scope, acceptance criteria>"`.
OUTPUT: post a concise assessment as a comment on this issue (#__ISSUE__) — what shipped, what's in flight, the top risks/gaps, and the reasoning behind the ranking. If the ranking actually changed, open ONE PR for `.github/loop/PRIORITIES.md`: `git switch -c codex/planner-__ISSUE__`, `git push -u origin codex/planner-__ISSUE__`, `gh pr create --base master --label codex --title "<title>" --body "<summary, Closes #__ISSUE__>"`, then `gh pr merge --squash --auto --delete-branch`. If the queue is already accurate, just close this issue (`gh issue close __ISSUE__`).
Do NOT make breaking public-API or architectural changes yourself — surface those in the assessment as notes for the human, never as auto-merged changes. Open the PR yourself from the shell with `gh`; do not use the make_pr tool (it is a no-op stub).
+30
View File
@@ -0,0 +1,30 @@
<!--
The SECURITY prompt — go-micro's security audit. Editable policy; the workflow
prepends the agent @mention and substitutes __ISSUE__ before posting. Keep
__ISSUE__ literal.
Deliberately conservative: it does NOT auto-merge fixes, and it does NOT publish
exploit details in public issues (responsible disclosure).
-->
Act as the security reviewer for go-micro. Audit for real, exploitable vulnerabilities — skip theoretical or lint-style noise.
GO-MICRO ATTACK SURFACE — weight these:
- **MCP gateway** (`gateway/mcp`) and **A2A gateway** (`gateway/a2a`) — untrusted input from agents/tools: auth/scope enforcement, injection into downstream RPC, SSRF via tool/agent URLs, rate-limit/circuit-breaker bypass, info leak in errors.
- **x402 payments** (`wrapper/x402`) — payment verification and settlement: signature/mandate validation, replay, budget-reservation races, facilitator auth (CDP bearer) handling, amount/network confusion.
- **Auth** (`auth/jwt`, `wrapper/auth`) — token validation, algorithm confusion, scope/priority rule bypass, missing checks on endpoints.
- **AI providers** (`ai/*`) — base-URL and endpoint handling: SSRF via config-controlled `BaseURL`, API keys leaking into logs/errors, TLS verification.
- **Agent tool loop** (`agent/`) — prompt injection reaching real tool calls, guardrail (`MaxSteps`/`LoopLimit`/`ApproveTool`) bypass, delegate/plan side effects.
- **Trust boundaries** — `server` RPC handlers, `broker` consumers, `store`/`registry` inputs, `transport` TLS defaults (v6 verifies by default — confirm nothing regressed).
- **The loop itself** — `.github/workflows/loop-*.yml`: the `CODEX_TRIGGER_TOKEN` PAT must never be echoed/leaked; workflow inputs must not enable script injection.
- **Dependencies** — run `govulncheck ./...` (install if needed) and inspect `go.mod` for known CVEs.
DEDUPE against open issues first.
HOW TO REPORT:
- **Known/public dependency CVEs**: file a `security` issue referencing the CVE + module; you MAY open a PR bumping to the patched version. Do NOT enable auto-merge.
- **Novel, exploitable vulnerabilities in this code** (not yet public): do NOT post an exploit or PoC in a public issue. File a CONCISE `security` + `needs-human` issue naming the class, location (file/function), and impact only — and note it should go through GitHub private vulnerability reporting. Do NOT open a public fix PR that reveals it.
- **Low-risk hardening**: a normal `security` issue is fine.
NEVER auto-merge a security change. Never weaken a control to make a test pass. Architectural/breaking fixes → `needs-human` with the tradeoff.
Post a summary as a comment on this issue (#__ISSUE__) — findings by severity, what you filed, what needs a human — then close it (`gh issue close __ISSUE__`). If you open a dependency-bump PR: `git switch -c loop/security-__ISSUE__`, `git push -u origin loop/security-__ISSUE__`, `gh pr create --base master --label codex --label security --title "<title>" --body "<summary, Closes #__ISSUE__>"` — then STOP, do NOT run `gh pr merge --auto`. Do not use the make_pr tool.
+14
View File
@@ -0,0 +1,14 @@
<!--
The TRIAGE prompt — go-micro's CI-failure feedback path. Editable policy; the
workflow prepends the agent @mention and substitutes __ISSUE__ (this tracking
issue) and __RUNURL__ (the failed run) before posting. Keep both literal.
-->
Triage the failed CI run at __RUNURL__. It may be the linter (Lint), the unit/integration tests (Run Tests), or the provider-conformance harness (Harness (E2E)).
Read the logs and root-cause each distinct failure. DEDUPE against open issues — if a failure matches an existing issue, comment "recurred" there instead of filing a duplicate.
For each genuine, self-contained defect, file a scoped issue (`gh issue create --label codex --label enhancement --title "<scoped fix>" --body "<root cause, where, acceptance criteria>"`) so the increment loop builds it and the next CI/harness run verifies it. A lint or test failure on master is a real regression — file it so it is fixed promptly; do NOT ignore it.
IGNORE only genuine transient flakes — live-model latency, provider outages, rate limits, network timeouts with no code cause (mostly relevant to the harness). Anything needing a breaking or architectural change: file it as `needs-human` and describe it, rather than auto-queuing it as a routine fix.
Close this issue (`gh issue close __ISSUE__`) when triage is done. Open any PR yourself from the shell with `gh`; do not use the make_pr tool.
-42
View File
@@ -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."
+70 -13
View File
@@ -2,8 +2,9 @@ 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.
# secrets. A second job runs the same harnesses against the live provider set and
# skips providers whose secrets are absent, so scheduled conformance
# remains safe in no-key forks while still failing configured providers that drift.
on:
push:
@@ -11,8 +12,22 @@ on:
pull_request:
branches: ["**"]
schedule:
- cron: "17 6 * * *" # daily, so the world is exercised even without changes
- cron: "17 * * * *" # hourly, so real-model conformance keeps pace with the dev/loop velocity
workflow_dispatch:
inputs:
providers:
description: "Comma-separated providers for live conformance (default: all supported)"
required: false
default: "anthropic,openai,gemini,groq,minimax,mistral,together,atlascloud"
harnesses:
description: "Comma-separated harnesses for live conformance"
required: false
default: "agent,universe,agent-flow,plan-delegate,a2a-stream-fallback"
require_configured:
description: "Fail selected live providers that do not have repository secrets"
required: false
type: boolean
default: false
jobs:
harness:
@@ -26,17 +41,13 @@ jobs:
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
- name: 0→1 and 0→hero developer-flow harness
run: make harness
harness-live:
name: Provider harnesses (live LLM, if keys present)
name: Provider harnesses (live LLM conformance)
runs-on: ubuntu-latest
# Only on the daily schedule or a manual run — never automatically on
# Only on the hourly 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.
@@ -47,13 +58,59 @@ jobs:
with:
go-version: stable
cache: true
- name: Provider conformance against configured live models
- name: Provider conformance against live models
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
ATLASCLOUD_API_KEY: ${{ secrets.ATLASCLOUD_API_KEY }}
run: go run ./internal/harness/provider-conformance
# Atlas Cloud's default chat model was failing the agent/tool-use
# conformance harnesses; run it against a stronger tool-use model.
# Override with an Actions variable ATLASCLOUD_MODEL if the exact
# catalog id differs (Atlas uses org/model ids).
ATLASCLOUD_MODEL: ${{ vars.ATLASCLOUD_MODEL || 'minimaxai/minimax-m3' }}
run: |
PROVIDERS="${{ github.event.inputs.providers || 'anthropic,openai,gemini,groq,minimax,mistral,together,atlascloud' }}"
HARNESSES="${{ github.event.inputs.harnesses || 'agent,universe,agent-flow,plan-delegate,a2a-stream-fallback' }}"
REQUIRE_CONFIGURED="${{ github.event.inputs.require_configured || 'false' }}"
args=(
-providers "$PROVIDERS"
-harnesses "$HARNESSES"
-summary-json provider-conformance-summary.json
-summary-markdown provider-conformance-summary.md
-capabilities-markdown provider-capabilities.md
)
if [ "$REQUIRE_CONFIGURED" = "true" ]; then
args+=( -require-configured )
fi
go run ./internal/harness/provider-conformance "${args[@]}"
- name: Publish provider conformance summary
if: always()
run: |
if [ -f provider-conformance-summary.md ]; then
cat provider-conformance-summary.md >> "$GITHUB_STEP_SUMMARY"
fi
if [ -f provider-capabilities.md ]; then
{
echo
echo "## Registered provider capabilities"
echo
cat provider-capabilities.md
} >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload provider conformance summary
if: always()
uses: actions/upload-artifact@v4
with:
name: provider-conformance
path: |
provider-conformance-summary.json
provider-conformance-summary.md
provider-capabilities.md
if-no-files-found: ignore
+60
View File
@@ -0,0 +1,60 @@
name: "Loop: Builder"
# Generated by `micro loop init`. A dispatch role of the autonomous loop: on a
# cadence it opens a fresh tracking issue and posts the instruction in
# .github/loop/prompts/builder.md to the agent (@codex).
#
# The workflow is the MECHANISM; that prompt file is the editable POLICY —
# change what this role does by editing the prompt, not this YAML. A FRESH
# issue per run is deliberate: agents derive the PR branch name from the
# triggering issue, so reusing one tracker collapses every run onto one branch.
#
# Gated on CODEX_TRIGGER_TOKEN: the agent ignores @mentions from the
# github-actions bot, so dispatch posts as a real user (a PAT). No token → no-op.
on:
workflow_dispatch: {}
schedule:
- cron: "29 * * * *"
permissions:
issues: write
concurrency:
group: loop-builder
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # needed to read the prompt file
- name: Dispatch builder
env:
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
HAS_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
REPO: ${{ github.repository }}
RUN_NUMBER: ${{ github.run_number }}
run: |
if [ "$HAS_TOKEN" != "true" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping (the agent ignores bot @mentions)."
exit 0
fi
PROMPT=".github/loop/prompts/builder.md"
if [ ! -f "$PROMPT" ]; then
echo "missing $PROMPT — run 'micro loop init'." >&2
exit 1
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "Loop: build increment #$RUN_NUMBER" \
--body "Autonomous builder pass. Direction: .github/loop/NORTH_STAR.md; queue: .github/loop/PRIORITIES.md.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching builder."
# The prompt file is the policy; strip its editorial <!-- --> header and
# substitute the tracking issue number (__ISSUE__) at runtime.
{
echo "@codex"
echo
sed -e '/<!--/,/-->/d' -e "s/__ISSUE__/$ISSUE_NUM/g" "$PROMPT"
} > "$RUNNER_TEMP/loop-body.md"
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body-file "$RUNNER_TEMP/loop-body.md"
+60
View File
@@ -0,0 +1,60 @@
name: "Loop: Coherence"
# Generated by `micro loop init`. A dispatch role of the autonomous loop: on a
# cadence it opens a fresh tracking issue and posts the instruction in
# .github/loop/prompts/coherence.md to the agent (@codex).
#
# The workflow is the MECHANISM; that prompt file is the editable POLICY —
# change what this role does by editing the prompt, not this YAML. A FRESH
# issue per run is deliberate: agents derive the PR branch name from the
# triggering issue, so reusing one tracker collapses every run onto one branch.
#
# Gated on CODEX_TRIGGER_TOKEN: the agent ignores @mentions from the
# github-actions bot, so dispatch posts as a real user (a PAT). No token → no-op.
on:
workflow_dispatch: {}
schedule:
- cron: "0 7 * * *"
permissions:
issues: write
concurrency:
group: loop-coherence
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # needed to read the prompt file
- name: Dispatch coherence
env:
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
HAS_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
REPO: ${{ github.repository }}
RUN_NUMBER: ${{ github.run_number }}
run: |
if [ "$HAS_TOKEN" != "true" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping (the agent ignores bot @mentions)."
exit 0
fi
PROMPT=".github/loop/prompts/coherence.md"
if [ ! -f "$PROMPT" ]; then
echo "missing $PROMPT — run 'micro loop init'." >&2
exit 1
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "Loop: coherence review #$RUN_NUMBER" \
--body "Autonomous coherence pass. Direction: .github/loop/NORTH_STAR.md; queue: .github/loop/PRIORITIES.md.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching coherence."
# The prompt file is the policy; strip its editorial <!-- --> header and
# substitute the tracking issue number (__ISSUE__) at runtime.
{
echo "@codex"
echo
sed -e '/<!--/,/-->/d' -e "s/__ISSUE__/$ISSUE_NUM/g" "$PROMPT"
} > "$RUNNER_TEMP/loop-body.md"
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body-file "$RUNNER_TEMP/loop-body.md"
+60
View File
@@ -0,0 +1,60 @@
name: "Loop: Planner"
# Generated by `micro loop init`. A dispatch role of the autonomous loop: on a
# cadence it opens a fresh tracking issue and posts the instruction in
# .github/loop/prompts/planner.md to the agent (@codex).
#
# The workflow is the MECHANISM; that prompt file is the editable POLICY —
# change what this role does by editing the prompt, not this YAML. A FRESH
# issue per run is deliberate: agents derive the PR branch name from the
# triggering issue, so reusing one tracker collapses every run onto one branch.
#
# Gated on CODEX_TRIGGER_TOKEN: the agent ignores @mentions from the
# github-actions bot, so dispatch posts as a real user (a PAT). No token → no-op.
on:
workflow_dispatch: {}
schedule:
- cron: "59 * * * *"
permissions:
issues: write
concurrency:
group: loop-planner
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # needed to read the prompt file
- name: Dispatch planner
env:
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
HAS_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
REPO: ${{ github.repository }}
RUN_NUMBER: ${{ github.run_number }}
run: |
if [ "$HAS_TOKEN" != "true" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping (the agent ignores bot @mentions)."
exit 0
fi
PROMPT=".github/loop/prompts/planner.md"
if [ ! -f "$PROMPT" ]; then
echo "missing $PROMPT — run 'micro loop init'." >&2
exit 1
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "Loop: planning review #$RUN_NUMBER" \
--body "Autonomous planner pass. Direction: .github/loop/NORTH_STAR.md; queue: .github/loop/PRIORITIES.md.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching planner."
# The prompt file is the policy; strip its editorial <!-- --> header and
# substitute the tracking issue number (__ISSUE__) at runtime.
{
echo "@codex"
echo
sed -e '/<!--/,/-->/d' -e "s/__ISSUE__/$ISSUE_NUM/g" "$PROMPT"
} > "$RUNNER_TEMP/loop-body.md"
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body-file "$RUNNER_TEMP/loop-body.md"
+76
View File
@@ -0,0 +1,76 @@
name: "Loop: Release"
# Generated by `micro loop init`. Cuts the next PATCH tag
# (vMAJOR.MINOR.PATCH+1) when the default branch has new commits
# since the latest such tag, and pushes it with a PAT (CODEX_TRIGGER_TOKEN) so any
# tag-triggered release workflow fires. Minor/major bumps stay with a human.
#
# The tag MUST be pushed with a PAT, not the default GITHUB_TOKEN: a tag pushed
# by GITHUB_TOKEN does not trigger other workflows (Actions blocks that recursion).
on:
workflow_dispatch: {}
schedule:
- cron: "0 23 * * *"
permissions:
contents: read
concurrency:
group: loop-release
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # need full history + all tags
# Do NOT persist the default GITHUB_TOKEN as a git credential: it would
# be sent on the PAT push below and override it, so the tag push would
# authenticate as github-actions[bot] and 403. Letting the PAT in the
# push URL be the only credential is the whole point.
persist-credentials: false
- name: Cut the next patch tag if there are new commits
env:
RELEASE_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN }}
REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_TOKEN" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping."
exit 0
fi
git fetch --tags --force
LATEST=$(git tag --list 'v*.*.*' --sort=-v:refname | head -1)
if [ -z "$LATEST" ]; then
echo "no vMAJOR.MINOR.PATCH tag found — aborting so nothing weird gets tagged."
exit 1
fi
echo "latest tag: $LATEST"
COUNT=$(git rev-list --count "$LATEST"..HEAD)
echo "commits since $LATEST: $COUNT"
if [ "$COUNT" -eq 0 ]; then
echo "no new commits since $LATEST — no release."
exit 0
fi
ver="${LATEST#v}"
major="${ver%%.*}"
rest="${ver#*.}"
minor="${rest%%.*}"
patch="${rest#*.}"
case "$major.$minor.$patch" in
[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "unexpected tag shape: $LATEST" ; exit 1 ;;
esac
NEXT="v${major}.${minor}.$((patch + 1))"
echo "cutting: $NEXT ($COUNT commits since $LATEST)"
git config user.name "loop release bot"
git config user.email "noreply@users.noreply.github.com"
git tag -a "$NEXT" -m "Release $NEXT — automated patch ($COUNT commits since $LATEST)"
git push "https://x-access-token:${RELEASE_TOKEN}@github.com/${REPO}.git" "$NEXT"
echo "Pushed $NEXT."
+60
View File
@@ -0,0 +1,60 @@
name: "Loop: Security"
# Generated by `micro loop init`. A dispatch role of the autonomous loop: on a
# cadence it opens a fresh tracking issue and posts the instruction in
# .github/loop/prompts/security.md to the agent (@codex).
#
# The workflow is the MECHANISM; that prompt file is the editable POLICY —
# change what this role does by editing the prompt, not this YAML. A FRESH
# issue per run is deliberate: agents derive the PR branch name from the
# triggering issue, so reusing one tracker collapses every run onto one branch.
#
# Gated on CODEX_TRIGGER_TOKEN: the agent ignores @mentions from the
# github-actions bot, so dispatch posts as a real user (a PAT). No token → no-op.
on:
workflow_dispatch: {}
schedule:
- cron: "0 6 * * 1"
permissions:
issues: write
concurrency:
group: loop-security
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # needed to read the prompt file
- name: Dispatch security
env:
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
HAS_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
REPO: ${{ github.repository }}
RUN_NUMBER: ${{ github.run_number }}
run: |
if [ "$HAS_TOKEN" != "true" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping (the agent ignores bot @mentions)."
exit 0
fi
PROMPT=".github/loop/prompts/security.md"
if [ ! -f "$PROMPT" ]; then
echo "missing $PROMPT — run 'micro loop init'." >&2
exit 1
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "Loop: security review #$RUN_NUMBER" \
--body "Autonomous security pass. Direction: .github/loop/NORTH_STAR.md; queue: .github/loop/PRIORITIES.md.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching security."
# The prompt file is the policy; strip its editorial <!-- --> header and
# substitute the tracking issue number (__ISSUE__) at runtime.
{
echo "@codex"
echo
sed -e '/<!--/,/-->/d' -e "s/__ISSUE__/$ISSUE_NUM/g" "$PROMPT"
} > "$RUNNER_TEMP/loop-body.md"
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body-file "$RUNNER_TEMP/loop-body.md"
+57
View File
@@ -0,0 +1,57 @@
name: "Loop: Triage"
# Generated by `micro loop init`. The feedback path of the evaluator: when a CI
# workflow (Harness (E2E), Lint, Run Tests) fails on a non-PR run, dispatch the agent
# (@codex) with the instruction in .github/loop/prompts/triage.md
# to root-cause the failure and file scoped fix issues back into the queue — so
# failures become fixes with no human in the middle. Gated on CODEX_TRIGGER_TOKEN.
on:
workflow_run:
workflows: ["Harness (E2E)", "Lint", "Run Tests"]
types: [completed]
permissions:
issues: write
concurrency:
group: loop-triage
cancel-in-progress: false
jobs:
triage:
# Only real failures on branch pushes/schedules — not PR-run failures, which
# the PR author already sees.
if: ${{ github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.event != 'pull_request' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # needed to read the prompt file
- name: Dispatch triage
env:
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
HAS_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.event.workflow_run.id }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
WORKFLOW_NAME: ${{ github.event.workflow_run.name }}
run: |
if [ "$HAS_TOKEN" != "true" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping."
exit 0
fi
PROMPT=".github/loop/prompts/triage.md"
if [ ! -f "$PROMPT" ]; then
echo "missing $PROMPT — run 'micro loop init'." >&2
exit 1
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "Loop: triage failed run $RUN_ID ($WORKFLOW_NAME)" \
--body "The '$WORKFLOW_NAME' workflow failed on a non-PR run: $RUN_URL")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching triage."
{
echo "@codex"
echo
sed -e '/<!--/,/-->/d' -e "s/__ISSUE__/$ISSUE_NUM/g" -e "s#__RUNURL__#$RUN_URL#g" "$PROMPT"
} > "$RUNNER_TEMP/loop-body.md"
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body-file "$RUNNER_TEMP/loop-body.md"
+1
View File
@@ -7,6 +7,7 @@ on:
types:
- opened
- reopened
- synchronize
branches:
- "**"
jobs:
+1
View File
@@ -62,6 +62,7 @@ examples/mcp/hello/hello
/plan-delegate
/agent-plan-delegate
/micro-mcp-gateway
/agent-ollama
# Local Jekyll / Bundler artifacts
internal/website/.bundle/
+28 -4
View File
@@ -7,9 +7,33 @@ These instructions apply to the entire repository.
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.
2. Run the relevant verification commands and capture their results
(`go build ./...`, `go test ./...`, `golangci-lint run ./...`).
3. Check `git status --short` and review the diff before finishing.
4. 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.
4. Create a uniquely-named branch under the `codex/` prefix (do not work on
`master`, and do not use a generic name like `work`):
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.
```sh
git switch -c codex/<issue-number>-<short-slug>
```
5. Stage the intended files and commit on that branch.
6. Open the pull request yourself with the GitHub CLI, which is installed in the
environment and whose `origin` points at this repository, then enable
auto-merge so GitHub merges it once the required CI checks pass:
```sh
git push -u origin HEAD
gh pr create --base master --label codex \
--title "<concise title>" \
--body "<summary of the change and testing, including 'Closes #<issue>'>"
gh pr merge --squash --auto --delete-branch
```
The branch should start with `codex/` and the PR should carry the `codex`
label. Auto-merge waits for the required status checks (build, tests,
golangci-lint) — never merge a PR manually before CI is green.
Do not just say that a PR was opened, and do **not** rely on the `make_pr` tool:
in this environment `make_pr` only records the title/body and never pushes a
branch or creates a PR. The task is not complete until `gh pr create` has opened
a real pull request and printed its URL.
+125 -3
View File
@@ -2,8 +2,130 @@
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.
Format follows [Keep a Changelog](https://keepachangelog.com/) and versions
follow [Semantic Versioning](https://semver.org/), matching the git tags and
[GitHub releases](https://github.com/micro/go-micro/releases) (`v6.MINOR.PATCH`).
Patch releases are cut automatically as the loop merges improvements; the
`[Unreleased]` section below is kept current between tags and rolled into the
next version when it ships.
> Earlier `2026.0x` headings are historical calendar-style markers from before
> v6 tagging; they are kept for continuity and not reused.
---
## [Unreleased]
### Added
- **First-agent examples CLI wayfinding** — `micro examples` now prints the maintained provider-free first-agent examples in copy/paste order. (`cmd/micro/`)
- **0→hero CLI entrypoint** — `micro zero-to-hero` now points developers at the maintained no-secret services → agents → workflows harness and runnable examples. (`cmd/micro/`)
### Fixed
- **Plan/delegate notify replays** — duplicate and replayed plan-delegate notifications are now idempotent, so resumed runs do not duplicate completed notifications. (`agent/`, `internal/harness/`)
- **Provider conformance scheduling** — provider conformance workflow dispatches now guard their scheduling path more reliably. (`.github/workflows/`)
### Documentation
- **First-agent quickstart numbering** — the first-agent on-ramp numbering is consistent across the README and website docs. (`README.md`, `internal/website/docs/`)
- **First-agent inspect command** — docs now use the maintained `micro inspect agent <name>` form. (`README.md`, `internal/website/docs/`)
---
## [6.3.16] - July 2026
### Added
- **No-secret agent demo CLI** — the CLI now surfaces `micro agent demo`, making the provider-free first-agent path discoverable from the installed binary. (`cmd/micro/`)
- **First-agent recovery doctor** — first-agent recovery checks now help diagnose install, scaffold, and provider setup issues before the live agent run. (`cmd/micro/`, `internal/website/docs/guides/`)
### Changed
- **Architecture lifecycle docs** — the architecture guide now leads with the services → agents → workflows lifecycle and the first-agent on-ramp. (`internal/website/docs/architecture.md`)
- **First-agent on-ramp** — README and website docs now lead new users through install troubleshooting, no-secret demos, the smallest first-agent example, debugging, and the 0→hero reference path in the same order. (`README.md`, `internal/website/docs/`)
### Fixed
- **Config close idempotency** — config close paths now tolerate repeated closes safely. (`config/`)
- **OpenTelemetry child span events** — agent traces now preserve child span events more reliably. (`agent/`)
### Documentation
- **Security reporting** — security docs now route vulnerability reports through GitHub Security Advisories. (`SECURITY.md`, `internal/website/docs/`)
- **Install troubleshooting** — the first-agent on-ramp now includes clearer install and PATH recovery guidance. (`internal/website/docs/guides/install-troubleshooting.md`)
---
## [6.3.15] - July 2026
### Added
- **Anthropic streaming** — the Anthropic provider now supports Messages SSE streaming and is registered as a streaming-capable provider, with capability docs and parser coverage. (`ai/anthropic/`, `internal/website/docs/guides/`)
- **AP2 mandate foundation for A2A** — the A2A gateway now has the shared payment-mandate foundation needed for AP2-style agent payment flows. (`gateway/a2a/`)
- **Smallest first-agent example** — a no-secret, mock-model first-agent example gives the on-ramp a minimal runnable starting point. (`examples/first-agent/`)
### Changed
- **First-agent CLI next steps** — CLI output now points new users toward the maintained first-agent path after scaffold/run milestones. (`cmd/micro/`)
### Fixed
- **Plan/delegate completion** — plan-delegate runs now preserve completed steps, guard ordering, require notify-before-completion, and stabilize checkpoint continuation paths. (`agent/`, `internal/harness/`)
- **Provider text tool calls** — AtlasCloud and weaker-model fallback paths now recover tagged, `Create`-suffixed, mixed text/tool-call, and follow-up tool calls more reliably. (`agent/`, `ai/atlascloud/`)
- **First-agent broker isolation** — the first-agent harness now isolates broker state more reliably across runs. (`internal/harness/`)
### Documentation
- **First-agent example path** — docs and website wayfinding now surface the smallest example, no-secret transcript, and 0→hero path together. (`README.md`, `internal/website/docs/`)
- **Agent operations guidance** — agent debugging docs now include operational failure guidance, inspect hints, and durable resume pointers. (`internal/website/docs/guides/`)
---
## [6.3.14] - July 2026
### Added
- **MiniMax provider** — run agents against MiniMax's `MiniMax-M3` model via its OpenAI-compatible endpoint, with tool calling and streaming; auto-detected from the base URL. (`ai/minimax/`)
- **`micro loop` security role** — a new opt-in loop role (`--roles …,security`) that periodically audits a repo for vulnerabilities and files `security` issues. It is deliberately conservative: it never auto-merges fixes and never publishes exploit detail in public issues (responsible disclosure), and risky fixes are marked `needs-human`. go-micro now runs it against its own attack surface (MCP/A2A gateways, x402, auth, provider URLs, agent tool loop, deps). (`cmd/micro/loop/`)
- **Agent run tracing** — agent model streaming and run-event kinds now emit richer trace detail for debugging agent execution. (`agent/`)
### Changed
- **Agent memory** — streamed agent replies are persisted in conversation memory so later turns can reference streamed responses. (`agent/`)
### Fixed
- **Plan/delegate completion** — agents now continue unfinished plan steps more reliably, fail checkpointed runs that leave delegated plans unfinished, recover from unknown plan-delegate tool calls, avoid duplicate side effects, and complete timeout paths deterministically. (`agent/`)
- **AtlasCloud tool calls** — streaming and request fallback handling now recovers tool-call results from provider responses that omit the expected structured fields. (`ai/atlascloud/`)
- **Agent preflight diagnostics** — provider setup failures now surface more actionable errors before an agent run starts. (`agent/`)
- **A2A fallback streams** — fallback stream validation is stricter for malformed or incomplete A2A streaming responses. (`gateway/a2a/`)
- **File-store test isolation** — file-store expiry and table tests are less timing-sensitive and isolate their state more reliably. (`store/file/`)
### Documentation
- **First-agent debugging path** — docs now include no-secret transcript checkpoints, durable resume examples, and clearer CLI/website wayfinding for first-agent debugging. (`README.md`, `internal/website/docs/`, `examples/agent-durable/`)
---
## [6.3.13] - July 2026
### Added
- **`micro loop`** — scaffold an autonomous improvement loop into any repository: GitHub Actions workflows dispatched to an @mention-driven coding agent, across up to five roles — `planner` (ranked queue), `builder` (top item as a single-concern PR, auto-merged on green CI), `triage` (CI failures → fix issues), and opt-in `coherence` (docs/CHANGELOG alignment) and `release` (daily patch tag). Each dispatch role's instruction lives in an editable `.github/loop/prompts/<role>.md` file — the workflow is the mechanism, the prompt is the policy — so a repo customizes behavior without forking the CLI. `micro loop init --roles …` writes it all; `micro loop verify` checks the wiring. This is the loop that maintains go-micro itself, generalized. (`cmd/micro/loop/`)
### Changed
- **x402 payments** — settlement now covers CDP facilitator authentication and conformance edge cases. (`wrapper/x402/`)
### Fixed
- **Plan/delegate harnessing** — side effects and notifications are now idempotent and deterministic across duplicate, alias, order-scoped, and reachability scenarios. (`agent/`, `internal/harness/`)
### Documentation
- **First-agent on-ramp** — quickstart docs now connect the no-secret first-agent transcript, example map, and 0→hero path. (`README.md`, `internal/website/docs/`)
- **Ollama provider docs** — the provider surface, capability matrix, and examples now document local and cloud behavior. (`internal/website/docs/`, `examples/agent-ollama/`)
---
## [6.3.12] - July 2026
### Added
- **Ollama provider** — run agents against open-weight models locally (`/api/chat`, NDJSON streaming) or via Ollama Cloud (OpenAI-compatible `/v1/chat/completions`, SSE), auto-detected from the base URL, with tool calling in both modes. Point any agent at a non-default endpoint with the new `agent.BaseURL` / `micro.AgentBaseURL` option. (`ai/ollama/`, `examples/agent-ollama/`)
- **Retrieval-backed agent memory** — agents can recall relevant prior turns by similarity, not just the recent window, with a summarizer hook that compacts older history so long conversations stay in budget. (`agent/`)
- **Scheduled flows** — a flow can run an agent (or any step) on a cron-style schedule, with the dispatch traced end to end. (`flow/`)
- **Flow verification/grader loop** — a workflow can grade its own step output against a rubric and retry until it passes, plus run-trace analysis to surface where a flow spends its time. (`flow/`)
- **A2A streaming & continuity** — outbound agent streaming flows through the A2A binding (`message/stream`), with `tasks/resubscribe` and `input-required` handoffs for multi-turn interop. (`gateway/a2a/`)
### Changed
- **Agent tool-call resilience** — opt-in retries around agent tool calls, and a fallback that executes tool calls emitted as text by weaker models so they still make progress. (`agent/`)
- **Hardened agent durability** — terminal failure statuses are classified and surfaced, and durable resume-after-restart is covered by tests. (`agent/`)
### Documentation
- **"Your first agent" walkthrough** and a canonical 0-to-hero reference path, lowering the on-ramp from install to a running agent. (`internal/website/docs/`)
- **Discord** linked prominently across the README, website nav/footer, and docs. (`https://discord.gg/G8Gk5j3uXr`)
---
@@ -19,7 +141,7 @@ else is additive. See the [v5 → v6 migration guide](internal/website/docs/guid
- **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/`)
- **A2A protocol — both directions** — `gateway/a2a` exposes registered agents over the open Agent2Agent (A2A) protocol so agents on other frameworks can discover and call them: Agent Cards are generated from registry metadata (the same way the MCP gateway derives tools), and incoming tasks are translated to the agent's existing `Agent.Chat` RPC, with no per-agent code (`micro a2a serve`). The outbound `a2a.Client` calls external A2A agents by URL, wired into `flow.A2A(url)` (a workflow step) and `delegate` to an `http(s)` URL (from inside an agent). An agent can also serve A2A **directly** without a gateway via `AgentA2A(addr)` (`a2a.NewAgentHandler`), handling tasks in-process. The JSON-RPC binding includes `message/send`, `message/stream` (SSE), `tasks/get`, multi-turn continuation by `taskId`/`contextId`, best-effort push notification callbacks, `tasks/resubscribe`, `input-required` handoffs, and card discovery. (`gateway/a2a/`, `cmd/micro/a2a/`)
- **Agents (`micro.NewAgent`)** — an agent is a service with an LLM inside: it discovers its assigned services as tools, runs the model's tool loop, registers a `Chat` RPC endpoint, and is reachable like any service. `Ask` for programmatic use; `micro chat` discovers and routes to agents; `micro agent list`/`describe`. (`agent/`)
- **Plan & delegate** — two built-in agent tools added to every agent: `plan` (an ordered, store-persisted plan surfaced back in the prompt) and `delegate` (hand a self-contained subtask to a registered agent over RPC, otherwise to an ephemeral sub-agent). No harness or graph — they're plain tools. (`agent/builtin.go`, `examples/agent-plan-delegate/`)
- **Agent guardrails** — `MaxSteps` (stop on count), `LoopLimit` (stop repeated no-progress calls; on by default), and `ApproveTool` (human-in-the-loop / policy gate before each action), enforced at the one point every tool call passes through. (`agent/`, guide + blog)
+11
View File
@@ -6,6 +6,17 @@ Thank you for your interest in contributing to Go Micro! This document provides
Be respectful, inclusive, and collaborative. We're all here to build great software together.
## How Go Micro is built
Go Micro is developed by an **autonomous improvement loop** — a planner, a
generator, and a separate evaluator, running as scheduled GitHub Actions with a
human setting direction. It's the framework's own thesis (an agent operating a
system) pointed at itself: an agent harness, built by agents. The full process —
the planner → generator → evaluator pipeline, the correctness-only merge gate, and
the guardrails — is documented in
[`internal/docs/CONTINUOUS_IMPROVEMENT.md`](internal/docs/CONTINUOUS_IMPROVEMENT.md).
Human contributions follow the same gate: green CI, one concern per PR.
## Getting Started
1. Fork the repository
+23 -6
View File
@@ -8,7 +8,7 @@ LDFLAGS = -X $(GIT_IMPORT).BuildDate=$(BUILD_DATE) -X $(GIT_IMPORT).GitCommit=$(
# 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
.PHONY: test test-race test-coverage harness install-smoke provider-conformance-mock provider-conformance lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
# Default target
help:
@@ -18,7 +18,9 @@ help:
@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 harness - Run deterministic getting-started and end-to-end harnesses"
@echo " make install-smoke - Verify the local install.sh and first-run CLI smoke path"
@echo " make provider-conformance-mock - Run cross-provider harness with deterministic mock provider"
@echo " make provider-conformance - Run harnesses against configured live providers"
@echo " make fmt - Format code"
@echo " make install-tools - Install development tools"
@@ -42,12 +44,27 @@ test-coverage:
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.
# Run the documented getting-started contracts plus the deterministic
# services → agents → workflows harnesses (mock LLM — no API key).
# This mirrors the default CI path so local dogfooding catches scaffold,
# run/chat/inspect, and 0→hero regressions before a PR is opened.
harness:
go run ./internal/harness/universe
$(MAKE) install-smoke
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
./internal/harness/zero-to-hero-ci/run.sh
go run ./internal/harness/agent-flow
go run ./internal/harness/plan-delegate
$(MAKE) provider-conformance-mock
# Verify the documented install script and first-run CLI command boundaries without
# provider keys or network access.
install-smoke:
./internal/harness/install-smoke/run.sh
# Run the shared provider conformance contract with the deterministic mock
# provider. This is the no-secret path used by CI and local dogfooding to keep
# provider-facing agent/tool semantics covered on every machine.
provider-conformance-mock:
go run ./internal/harness/provider-conformance -providers mock
# Run the same harnesses against every configured live provider. Providers
# without API keys are skipped; configured providers must pass.
+82 -11
View File
@@ -1,8 +1,12 @@
# Go Micro [![Go.Dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/go-micro.dev/v6?tab=doc) [![Go Report Card](https://goreportcard.com/badge/github.com/go-micro/go-micro)](https://goreportcard.com/report/github.com/go-micro/go-micro)
# Go Micro [![Go.Dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/go-micro.dev/v6?tab=doc) [![Go Report Card](https://goreportcard.com/badge/github.com/go-micro/go-micro)](https://goreportcard.com/report/github.com/go-micro/go-micro) [![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/G8Gk5j3uXr)
Go Micro is an **agent harness** and service framework for Go.
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.
**Community:** questions, ideas, or just want to build alongside us? [Join the Discord](https://discord.gg/G8Gk5j3uXr).
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 the harness as Go code. Build an agent and it gets a model, memory, tools, planning, delegation, guardrails, and service discovery; it is reachable over [MCP](https://modelcontextprotocol.io/) and [A2A](https://a2a-protocol.org). Write services and every endpoint becomes an AI-callable tool. Orchestrate the deterministic parts with durable flows. Agents, services, and flows share one runtime because an agent is a distributed system, and building one is building a service.
## Sponsors
@@ -12,7 +16,7 @@ A harness is the runtime around an agent: the tools it can call, the memory it k
&nbsp;&nbsp;
<a href="https://go-micro.dev/blog/8"><img src="https://www.atlascloud.ai/logo.svg" height="26" /></a>
**Want to support Go Micro and see your logo here?** [Become a sponsor](https://discord.gg/WeMU5AGxD) — reach out on Discord.
**Want to support Go Micro and see your logo here?** [Become a sponsor](https://discord.gg/G8Gk5j3uXr) — reach out on Discord.
## Commercial Support
@@ -21,11 +25,13 @@ Running Go Micro in production, or building on it and want help? Paid **support,
## Contents
- [Quick Start](#quick-start)
- [First agent on-ramp](#first-agent-on-ramp)
- [Why an Agent Harness](#why-an-agent-harness)
- [Writing Services](#writing-services)
- [Building Agents](#building-agents) — [Plan & Delegate](#plan--delegate), [Pluggable](#batteries-included-pluggable), [Paid tools (x402)](#paid-tools-x402), [A2A](#reachable-by-other-agents-a2a)
- [Features](#features)
- [CLI](#cli)
- [Autonomous improvement loop](#autonomous-improvement-loop)
- [Multi-Service Projects](#multi-service-projects)
- [Data Model](#data-model)
- [AI Providers](#ai-providers)
@@ -42,9 +48,11 @@ Install the CLI:
curl -fsSL https://go-micro.dev/install.sh | sh
# Or with Go
go install go-micro.dev/v6/cmd/micro@v6
go install go-micro.dev/v6/cmd/micro@latest
```
If install or `PATH` checks fail, use the [install troubleshooting guide](internal/website/docs/guides/install-troubleshooting.md) before scaffolding your first service.
### Fastest start — no API key
Scaffold a service, run it, call it:
@@ -62,6 +70,61 @@ curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call \
-H 'Content-Type: application/json' -d '{"name":"World"}'
```
This install → scaffold → run → call path is covered by no-secret CI harnesses. To
verify just the local installer and first-run CLI boundaries without network
access or provider keys, use:
```bash
make install-smoke
```
To run the broader local contract (including the [0→hero services → agents → workflows path](internal/website/docs/guides/zero-to-hero.md),
chat/inspect CLI boundaries, and deploy dry-run), use:
```bash
make harness
```
### First agent on-ramp
After install and the first `micro new`/`micro run` smoke check, take the
walkable agent path in this order:
1. [Install troubleshooting](internal/website/docs/guides/install-troubleshooting.md) — verify the binary installer or `go install`, `PATH`, `micro --version`, and the no-secret smoke path before agent work.
2. `micro agent demo` — print the provider-free first-agent demo command and next docs steps from the installed CLI.
3. `micro examples` — print the maintained provider-free runnable examples in copy/paste order.
4. `micro zero-to-hero` — print the maintained one-command no-secret lifecycle harness and runnable examples.
5. [Smallest first-agent example](examples/first-agent/) — run one service-backed agent with a mock model and no provider key.
6. [No-secret first-agent transcript](internal/website/docs/guides/no-secret-first-agent.md) — run the
maintained support agent with a mock model and see services → agents → workflows succeed without a key.
7. [Your First Agent](internal/website/docs/guides/your-first-agent.md) — build a
service-backed agent and talk to it with `micro chat`.
8. [Debugging your agent](internal/website/docs/guides/debugging-agents.md) — use
`micro inspect agent <name>`, run history, memory, and provider checks when the first
conversation does something unexpected.
9. [0→hero Reference](internal/website/docs/guides/zero-to-hero.md) — complete the
services → agents → workflows loop with scaffold, run, chat, inspect, flow
history, and deploy dry-run commands that match the maintained harness.
### Autonomous improvement loop
Want the same services → agents → workflows lifecycle applied to your
repository? `micro loop` scaffolds the autonomous improvement loop used by Go
Micro itself: a North Star, ranked issue queue, role prompts, GitHub Actions
workflows, and verification for CI-gated PRs.
```bash
micro loop init --roles all
micro loop verify
```
Before turning on the schedule, configure a dispatch token such as
`CODEX_TRIGGER_TOKEN`, protect the default branch with required CI checks
(`go build ./...`, `go test ./...`, and `golangci-lint run ./...` for this
repository), and seed `.github/loop/PRIORITIES.md` with one scoped issue per
increment. See the [`micro loop` quickstart](internal/website/docs/guides/micro-loop.md)
for the setup checklist and operating model.
### Generate from a prompt — with an LLM key
Set a provider key, describe what you want, and the AI designs services, writes handlers, compiles, and starts them:
@@ -235,7 +298,7 @@ Just as a service composes pluggable abstractions (registry, broker, store), an
```go
agent := micro.NewAgent("assistant",
micro.AgentProvider("anthropic"), // model — swap the provider
micro.AgentMemory(micro.NewInMemory(50)), // memory — default is store-backed & durable
micro.AgentCompactMemory(40, 12), // memory — durable, summarized, recallable
micro.AgentTool("weather", "Get the weather for a city",
map[string]any{"city": map[string]any{"type": "string"}},
func(ctx context.Context, in map[string]any) (string, error) {
@@ -245,7 +308,7 @@ agent := micro.NewAgent("assistant",
)
```
**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`.
**Memory** is durable and store-backed by default (Postgres, NATS KV, or file), so an agent picks up where it left off after a restart — or supply your own with `AgentMemory`. Long-running agents can opt into `AgentCompactMemory(maxMessages, keepRecent)`: older turns are collapsed into a deterministic summary, recent turns stay verbatim, and relevant archived turns are recalled on future asks without replaying the whole conversation. **Tools** are your services automatically, plus any function you register with `AgentTool`.
### Paid tools (x402)
@@ -253,9 +316,9 @@ Every endpoint is an AI-callable tool — and it can be a *paid* tool. Go Micro
```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
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
micro mcp serve --x402_config x402.json
```
See the [Payments (x402) guide](internal/website/docs/guides/x402-payments.md).
@@ -296,7 +359,7 @@ MCP exposes your services as tools; A2A exposes your agents as agents. See the [
| 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 |
| 9 LLM providers | Anthropic, OpenAI, Gemini, Groq, Mistral, Together, Atlas Cloud, MiniMax, Ollama (local + cloud) |
| Interactive console | `micro run` includes a chat console for talking to services |
| Service generation | `micro run --prompt` — describe a system, get running services |
@@ -391,8 +454,10 @@ Swap providers with a single import — same interface everywhere:
| 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` |
| Together AI | `meta-llama/Llama-3.3-70B-Instruct-Turbo` |
| Atlas Cloud | `deepseek-ai/DeepSeek-V3-0324` |
| MiniMax | `MiniMax-M3` |
| Ollama | `llama3.2` (local) |
```go
m := ai.New("anthropic", ai.WithAPIKey(key))
@@ -401,10 +466,14 @@ resp, _ := m.Generate(ctx, &ai.Request{Prompt: "hello"})
## Examples
New to agents? Follow the [first-agent on-ramp](#first-agent-on-ramp), then use the [examples index](examples/README.md) for the full services → agents → workflows map.
- [hello-world](examples/hello-world/) — Basic RPC service
- [multi-service](examples/multi-service/) — Multiple services in one binary
- [mcp](examples/mcp/) — MCP integration with AI agents
- [first-agent](examples/first-agent/) — Smallest provider-free service-backed agent
- [agent-plan-delegate](examples/agent-plan-delegate/) — Agent planning and multi-agent delegation
- [agent-durable](examples/agent-durable/) — Checkpoint and resume an agent run without replaying completed tool side effects
- [grpc-interop](examples/grpc-interop/) — Call go-micro from any gRPC client
See [all examples](examples/README.md).
@@ -413,6 +482,8 @@ See [all examples](examples/README.md).
- [Getting Started](internal/website/docs/getting-started.md)
- [AI Integration](internal/website/docs/ai-integration.md)
- [Your First Agent](internal/website/docs/guides/your-first-agent.md)
- [0→hero Reference](internal/website/docs/guides/zero-to-hero.md)
- [Agents and Workflows](internal/website/docs/guides/agents-and-workflows.md)
- [Agent Design](internal/docs/AGENT_DESIGN.md)
- [Plan & Delegate](internal/website/docs/guides/plan-delegate.md)
+6 -5
View File
@@ -15,7 +15,8 @@ The full, current roadmap lives at **[go-micro.dev/docs/roadmap](https://go-micr
## 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
flows, the MCP and A2A gateways (both directions, including A2A streaming,
push notifications, and multi-turn continuation), x402 paid tools, secure by
default.
## Principles
@@ -41,14 +42,14 @@ default.
## Next — agentic depth
- **Durable agent loop** — resume a long run via `Checkpoint` (flows already do).
- **Streaming** — `ai.Stream` + A2A `message/stream`, end to end.
- **Streaming** — broaden provider-backed `ai.Stream` coverage and keep chat/A2A streaming end to end.
- **Agent observability** — `RunInfo` → OpenTelemetry spans.
## Later
- Memory management (summarization, retrieval/RAG); human-in-the-loop pause/resume;
x402 live-facilitator conformance and paid remote tools with spend caps; A2A
streaming, push notifications, and multi-turn tasks.
richer A2A live-stream reconnection (`tasks/resubscribe`) and `input-required`
handoffs.
## Developer experience (ongoing)
@@ -65,7 +66,7 @@ hosted service, enterprise tier, or venture funding. See
## 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
[Discord](https://discord.gg/G8Gk5j3uXr). Include tests, run `make test` and
`make lint`.
## Version support
+4 -5
View File
@@ -17,11 +17,11 @@ We actively support the following versions of go-micro:
### How to Report
Send security vulnerability reports to: **security@go-micro.dev**
Or use GitHub's private security advisory feature:
Use GitHub's private security advisory feature:
https://github.com/micro/go-micro/security/advisories/new
This keeps vulnerability reports private, ties follow-up to the affected repository, and avoids relying on project email routing.
### What to Include
Please include as much of the following information as possible:
@@ -174,6 +174,5 @@ We currently do not offer a bug bounty program, but we greatly appreciate respon
For security questions that are not vulnerabilities, please:
- Open a discussion: https://github.com/micro/go-micro/discussions
- Join Discord: https://discord.gg/WeMU5AGxD
- Email: support@go-micro.dev
- Join Discord: https://discord.gg/G8Gk5j3uXr
+102
View File
@@ -0,0 +1,102 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/gateway/a2a"
)
func TestA2AStreamUsesAgentChatPathWithTools(t *testing.T) {
var sawTool bool
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
t.Fatal("model was not wired with agent tool handler")
}
result := opts.ToolHandler(ctx, ai.ToolCall{
ID: "call-1",
Name: "echo",
Input: map[string]any{"value": "a2a-stream"},
})
if !strings.Contains(result.Content, "a2a-stream-ok") {
t.Fatalf("tool result = %q, want marker", result.Content)
}
return &ai.Response{Answer: "streamed " + result.Content}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("stream-agent"), WithTool("echo", "echo text", nil, func(ctx context.Context, input map[string]any) (string, error) {
sawTool = true
if info, ok := ai.RunInfoFrom(ctx); !ok || info.RunID == "" || info.Agent != "stream-agent" {
t.Fatalf("RunInfo = %+v ok=%v, want stream-agent run", info, ok)
}
if input["value"] != "a2a-stream" {
t.Fatalf("tool input = %+v, want a2a-stream", input)
}
return "a2a-stream-ok", nil
}))
h := a2a.NewAgentStreamHandler(
a2a.Card("stream-agent", "http://example.invalid/stream-agent", "", nil),
func(ctx context.Context, text string) (string, error) {
resp, err := a.Ask(ctx, text)
if err != nil {
return "", err
}
return resp.Reply, nil
},
a.streamAskAI,
)
body := []byte(`{"jsonrpc":"2.0","id":1,"method":"message/stream","params":{"message":{"role":"user","parts":[{"kind":"text","text":"run stream tool"}],"kind":"message"}}}`)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if !sawTool {
t.Fatal("A2A stream did not execute the agent tool path")
}
if ct := rr.Result().Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
t.Fatalf("content-type = %q, want text/event-stream", ct)
}
if !strings.Contains(rr.Body.String(), "a2a-stream-ok") {
t.Fatalf("stream body missing tool marker: %s", rr.Body.String())
}
var final struct {
Result struct {
Status struct {
State string `json:"state"`
} `json:"status"`
Artifacts []struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"artifacts"`
} `json:"result"`
Error any `json:"error"`
}
for _, line := range strings.Split(strings.TrimSpace(rr.Body.String()), "\n") {
line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "data: "))
if line == "" {
continue
}
if err := json.Unmarshal([]byte(line), &final); err != nil {
t.Fatalf("decode event %q: %v", line, err)
}
}
if final.Error != nil {
t.Fatalf("final event error: %+v", final.Error)
}
if final.Result.Status.State != "completed" {
t.Fatalf("final state = %q, want completed", final.Result.Status.State)
}
if len(final.Result.Artifacts) != 1 || len(final.Result.Artifacts[0].Parts) != 1 || !strings.Contains(final.Result.Artifacts[0].Parts[0].Text, "a2a-stream-ok") {
t.Fatalf("final artifacts = %+v, want tool marker", final.Result.Artifacts)
}
}
+245 -26
View File
@@ -19,10 +19,12 @@ import (
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
pb "go-micro.dev/v6/agent/proto"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/gateway/a2a"
"go-micro.dev/v6/server"
"go-micro.dev/v6/store"
@@ -32,6 +34,7 @@ import (
_ "go-micro.dev/v6/ai/gemini"
_ "go-micro.dev/v6/ai/groq"
_ "go-micro.dev/v6/ai/mistral"
_ "go-micro.dev/v6/ai/ollama"
_ "go-micro.dev/v6/ai/openai"
_ "go-micro.dev/v6/ai/together"
)
@@ -42,6 +45,7 @@ type Agent interface {
Init(...Option)
Options() Options
Ask(ctx context.Context, message string) (*Response, error)
Stream(ctx context.Context, message string) (ai.Stream, error)
Run() error
Stop() error
String() string
@@ -85,6 +89,16 @@ type agentImpl struct {
// Both are surfaced to tool wrappers via ai.RunInfo on the context.
runID string
parentRunID string
// pause records a guardrail approval pause raised during the current
// Ask. The model provider only sees a refused tool result; the agent
// converts it into a durable paused run instead of completing the run.
pause *approvalPause
// currentRun points at the checkpoint record for the Ask currently
// holding mu. Tool execution updates it so resumed runs can reuse
// completed tool results without replaying side effects.
currentRun *flow.Run
}
// New creates a new Agent.
@@ -127,19 +141,38 @@ func (a *agentImpl) String() string {
}
func (a *agentImpl) setup() {
a.setupWithToolHandler(nil)
}
func (a *agentImpl) setupWithToolHandler(handler ai.ToolHandler) {
var modelOpts []ai.Option
modelOpts = append(modelOpts, ai.WithAPIKey(a.opts.APIKey))
if a.opts.Model != "" {
modelOpts = append(modelOpts, ai.WithModel(a.opts.Model))
}
if a.opts.BaseURL != "" {
modelOpts = append(modelOpts, ai.WithBaseURL(a.opts.BaseURL))
}
a.tools = ai.NewTools(a.opts.Registry, ai.ToolClient(a.opts.Client))
modelOpts = append(modelOpts, ai.WithToolHandler(a.toolHandler()))
// Reuse the existing tools instance: its name map is populated by
// discoverTools, and rebuilding it here would orphan a base handler that
// already captured the old instance (breaking StreamAsk tool resolution).
if a.tools == nil {
a.tools = ai.NewTools(a.opts.Registry, ai.ToolClient(a.opts.Client))
}
if handler == nil {
handler = a.toolHandler()
}
modelOpts = append(modelOpts, ai.WithToolHandler(handler))
a.model = ai.New(a.opts.Provider, modelOpts...)
if a.opts.TraceProvider != nil && a.model != nil {
if a.model != nil {
a.model = a.tracedModel(a.model)
}
if a.mem != nil {
return
}
// Memory is pluggable. Use the configured one, otherwise the default
// store-backed memory — except ephemeral sub-agents, which keep an
// isolated, non-persistent context.
@@ -148,6 +181,10 @@ func (a *agentImpl) setup() {
a.mem = a.opts.Memory
case a.ephemeral:
a.mem = NewInMemory(a.opts.HistoryLimit)
case a.opts.MemoryCompaction.MaxMessages > 0:
a.mem = NewCompactingMemoryWithOptions(a.stateStore(), "history", a.opts.MemoryCompaction)
case a.opts.MemoryRetrievalLimit > 0:
a.mem = NewRetrievalMemory(a.stateStore(), "history", a.opts.MemoryRetrievalLimit)
default:
a.mem = NewMemory(a.stateStore(), "history", a.opts.HistoryLimit)
}
@@ -168,6 +205,46 @@ func (a *agentImpl) stateStore() store.Store {
// Ask sends a message and returns the agent's response.
// This is the programmatic API for direct use.
func (a *agentImpl) Ask(ctx context.Context, message string) (*Response, error) {
return a.ask(ctx, message, a.parentRunID)
}
// Stream sends a message and returns a streaming model response. Tool-calling
// agent runs still use Ask; Stream is for chat turns where immediate token
// delivery is more important than tool orchestration.
func (a *agentImpl) Stream(ctx context.Context, message string) (ai.Stream, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.model == nil {
a.setup()
}
toolList, err := a.discoverTools()
if err != nil {
return nil, fmt.Errorf("discover tools: %w", err)
}
a.mem.Add("user", message)
stream, err := a.model.Stream(ctx, &ai.Request{
Prompt: message,
SystemPrompt: a.buildPrompt(),
Tools: toolList,
Messages: a.mem.Messages(),
})
if err != nil {
return nil, err
}
return &memoryRecordingStream{stream: stream, memory: a.mem}, nil
}
// Pending returns checkpointed agent runs that have not completed. It mirrors
// flow.Pending for startup recovery loops that drain durable agent work.
func Pending(ctx context.Context, ag Agent) ([]flow.Run, error) {
a, ok := ag.(*agentImpl)
if !ok {
return nil, fmt.Errorf("agent pending: unsupported agent implementation %T", ag)
}
return a.pending(ctx)
}
func (a *agentImpl) ask(ctx context.Context, message, parentRunID string) (*Response, error) {
a.mu.Lock()
defer a.mu.Unlock()
@@ -175,37 +252,136 @@ func (a *agentImpl) Ask(ctx context.Context, message string) (*Response, error)
a.setup()
}
return a.askLocked(ctx, uuid.New().String(), message, parentRunID, nil, true)
}
func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID string, existing *flow.Run, addUserMessage bool) (*Response, error) {
toolList, err := a.discoverTools()
if err != nil {
return nil, fmt.Errorf("discover tools: %w", err)
}
a.mem.Add("user", message)
if addUserMessage {
a.mem.Add("user", message)
}
a.steps = 0
a.calls = map[string]int{}
a.pause = nil
// Correlate this run's tool calls and surface lineage to wrappers.
a.runID = uuid.New().String()
a.runID = runID
ctx = ai.WithRunInfo(ctx, ai.RunInfo{
RunID: a.runID,
ParentID: a.parentRunID,
ParentID: parentRunID,
Agent: a.opts.Name,
})
run := a.newCheckpointRun(runID, message, parentRunID, existing)
a.currentRun = &run
defer func() { a.currentRun = nil }()
if err := a.saveRun(ctx, run); err != nil {
return nil, err
}
ctx, endRun := a.startRun(ctx, message)
if existing != nil {
a.recordTimelineEvent(ctx, RunEvent{Time: time.Now(), RunID: runID, ParentID: parentRunID, Agent: a.opts.Name, Kind: "resume", Name: run.State.Stage})
}
defer func() { endRun(err) }()
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
messages := a.mem.Messages()
if recall, ok := a.mem.(MemoryRecall); ok && a.opts.MemoryRecallLimit > 0 {
if recalled := recall.Recall(message, a.opts.MemoryRecallLimit); len(recalled) > 0 {
messages = append([]ai.Message{{
Role: "system",
Content: "Relevant recalled memory follows; use it as durable prior context without assuming the whole conversation was replayed.",
}}, append(recalled, messages...)...)
}
}
// Some providers satisfy a saved plan one outstanding item per turn,
// especially when the final item delegates to another agent. Allow enough
// continuations for the services → agents → workflows harness to complete
// every planned side effect without weakening the final unfinished-plan guard.
const maxPlanCompletionTurns = 6
var resp *ai.Response
for planCompletionTurn := 0; ; planCompletionTurn++ {
resp, err = ai.GenerateWithRetry(ctx, a.model, &ai.Request{
Prompt: message,
SystemPrompt: a.buildPrompt(),
Tools: toolList,
Messages: messages,
}, ai.GeneratePolicy{
Timeout: a.opts.ModelTimeout,
MaxAttempts: a.opts.ModelMaxAttempts,
Backoff: a.opts.ModelRetryBackoff,
})
if err != nil {
run.Status = agentRunFailureStatus(err)
err = agentOperationalError(err)
if a.currentRun != nil {
run.Steps = a.currentRun.Steps
}
if len(run.Steps) == 0 {
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
}
run.Steps[0].Status = run.Status
run.Steps[0].Error = err.Error()
_ = a.saveRun(ctx, run)
return nil, err
}
if a.pause != nil && a.opts.Checkpoint != nil {
run.Status = "paused"
run.State.Stage = agentApprovalStep
run.State.Data = []byte(message)
if a.pause.Tool == toolHumanInput {
run.State.Stage = agentInputStep
_ = run.State.Set(inputPause{OriginalMessage: message, Prompt: a.pause.Message})
}
run.Steps[0].Status = "paused"
run.Steps[0].Error = a.pause.Message
run.Steps[0].Result = a.pause.Tool
if err := a.saveRun(ctx, run); err != nil {
return nil, err
}
return nil, fmt.Errorf("agent run %s paused for approval: %s", run.ID, a.pause.Message)
}
if len(resp.ToolCalls) == 0 {
if calls, answer, ok := a.executeTextToolCalls(ctx, resp.Reply, toolList); ok {
resp.ToolCalls = calls
if resp.Answer == "" {
resp.Answer = answer
}
trimmedReply := strings.TrimSpace(resp.Reply)
if strings.HasPrefix(trimmedReply, "{") || strings.HasPrefix(trimmedReply, "[") || strings.HasPrefix(trimmedReply, "```") {
resp.Reply = ""
}
}
} else if calls, answer, ok := a.executeAdditionalTextToolCalls(ctx, resp.Reply, toolList, resp.ToolCalls); ok {
resp.ToolCalls = append(resp.ToolCalls, calls...)
if answer != "" {
if resp.Answer == "" {
resp.Answer = answer
} else {
resp.Answer += "\n" + answer
}
}
}
if a.opts.Checkpoint != nil {
if unfinished := a.unfinishedPlanSteps(); len(unfinished) > 0 && planCompletionTurn < maxPlanCompletionTurns {
if resp.Reply != "" {
a.mem.Add("assistant", resp.Reply)
}
if resp.Answer != "" {
a.mem.Add("assistant", resp.Answer)
}
message = fmt.Sprintf("Continue the same run by calling the required tool(s) for the unfinished plan steps below. Do not repeat completed work, do not provide a final answer yet, and complete at least one unfinished step this turn if a matching tool is available. Unfinished plan steps: %s", strings.Join(unfinished, ", "))
a.mem.Add("user", message)
messages = a.mem.Messages()
continue
}
}
break
}
if resp.Reply != "" {
@@ -223,24 +399,62 @@ func (a *agentImpl) Ask(ctx context.Context, message string) (*Response, error)
reply += resp.Answer
}
return &Response{
res := &Response{
Reply: reply,
ToolCalls: resp.ToolCalls,
Agent: a.opts.Name,
RunID: a.runID,
ParentID: a.parentRunID,
}, nil
ParentID: parentRunID,
}
if a.opts.Checkpoint != nil {
if unfinished := a.unfinishedPlanSteps(); len(unfinished) > 0 {
err = fmt.Errorf("agent run %s has unfinished plan steps: %s", run.ID, strings.Join(unfinished, ", "))
run.Status = "failed"
run.State.Stage = agentAskStep
run.State.Data = []byte(message)
if a.currentRun != nil {
run.Steps = a.currentRun.Steps
}
if len(run.Steps) == 0 {
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
}
run.Steps[0].Status = "failed"
run.Steps[0].Error = err.Error()
_ = a.saveRun(ctx, run)
return nil, err
}
}
run.Status = "done"
run.State.Stage = ""
if b, marshalErr := json.Marshal(res); marshalErr == nil {
run.State.Data = b
}
if a.currentRun != nil {
run.Steps = a.currentRun.Steps
}
if len(run.Steps) == 0 {
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
}
run.Steps[0].Status = "done"
run.Steps[0].Attempts++
run.Steps[0].Result = reply
if err := a.saveRun(ctx, run); err != nil {
return nil, err
}
return res, nil
}
// Chat implements the proto AgentHandler interface for RPC.
// @example {"message": "What tasks are overdue?"}
func (a *agentImpl) Chat(ctx context.Context, req *pb.ChatRequest, rsp *pb.ChatResponse) error {
resp, err := a.Ask(ctx, req.Message)
resp, err := a.ask(ctx, req.Message, req.ParentId)
if err != nil {
return err
}
rsp.Reply = resp.Reply
rsp.Agent = resp.Agent
rsp.RunId = resp.RunID
rsp.ParentId = resp.ParentID
for _, tc := range resp.ToolCalls {
input, _ := json.Marshal(tc.Input)
rsp.ToolCalls = append(rsp.ToolCalls, &pb.ToolCall{
@@ -259,14 +473,19 @@ func (a *agentImpl) Run() error {
a.setup()
}
a.server = server.NewServer(
serverOpts := []server.Option{
server.Name(a.opts.Name),
server.Address(a.opts.Address),
server.Registry(a.opts.Registry),
server.Metadata(map[string]string{
"type": "agent",
"services": strings.Join(a.opts.Services, ","),
}),
)
}
if a.opts.Broker != nil {
serverOpts = append(serverOpts, server.Broker(a.opts.Broker))
}
a.server = server.NewServer(serverOpts...)
_ = pb.RegisterAgentHandler(a.server, a)
@@ -280,13 +499,13 @@ func (a *agentImpl) Run() error {
// 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) {
handler := a2a.NewAgentStreamHandler(card, func(ctx context.Context, text string) (string, error) {
resp, err := a.Ask(ctx, text)
if err != nil {
return "", err
}
return resp.Reply, nil
})
}, a.streamAskAI)
go func() {
if err := http.ListenAndServe(a.opts.A2AAddress, handler); err != nil {
fmt.Printf("agent %s A2A server: %v\n", a.opts.Name, err)
+49
View File
@@ -1,7 +1,11 @@
package agent
import (
"context"
"testing"
pb "go-micro.dev/v6/agent/proto"
"go-micro.dev/v6/ai"
)
func TestNew(t *testing.T) {
@@ -34,6 +38,51 @@ func TestNew(t *testing.T) {
}
}
func TestChatResponseIncludesRunIDs(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
return &ai.Response{Reply: "ok"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("chat-run"))
var rsp pb.ChatResponse
if err := a.Chat(context.Background(), &pb.ChatRequest{Message: "hello"}, &rsp); err != nil {
t.Fatalf("Chat: %v", err)
}
if rsp.RunId == "" {
t.Fatal("Chat response RunId is empty")
}
if rsp.Agent != "chat-run" {
t.Errorf("Agent = %q, want chat-run", rsp.Agent)
}
if rsp.ParentId != "" {
t.Errorf("ParentId = %q, want empty", rsp.ParentId)
}
}
func TestChatRequestParentIDPropagatesToResponse(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
info, ok := ai.RunInfoFrom(ctx)
if !ok {
t.Fatal("RunInfo missing from model context")
}
if info.ParentID != "flow-run-123" {
t.Fatalf("RunInfo.ParentID = %q, want flow-run-123", info.ParentID)
}
return &ai.Response{Reply: "ok"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("chat-child"))
var rsp pb.ChatResponse
if err := a.Chat(context.Background(), &pb.ChatRequest{Message: "hello", ParentId: "flow-run-123"}, &rsp); err != nil {
t.Fatalf("Chat: %v", err)
}
if rsp.ParentId != "flow-run-123" {
t.Errorf("ParentId = %q, want flow-run-123", rsp.ParentId)
}
}
func TestBuildPrompt(t *testing.T) {
// Custom prompt
a := New(Name("test"), Prompt("custom prompt")).(*agentImpl)
+381 -7
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"strings"
"time"
"go-micro.dev/v6/ai"
codecBytes "go-micro.dev/v6/codec/bytes"
@@ -20,8 +21,9 @@ import (
// the discovered service tools. There is no separate harness or graph:
// the LLM calls them like any other tool.
const (
toolPlan = "plan"
toolDelegate = "delegate"
toolPlan = "plan"
toolDelegate = "delegate"
toolHumanInput = "request_input"
)
// builtinTools returns the tool definitions exposed to the model in
@@ -41,6 +43,18 @@ func builtinTools() []ai.Tool {
},
},
},
{
Name: toolHumanInput,
OriginalName: toolHumanInput,
Description: "Pause this agent run when you need missing information, a decision, or other human input before you can continue. " +
"The run is checkpointed as input-required and can be resumed with the human response without losing completed tool history.",
Properties: map[string]any{
"prompt": map[string]any{
"type": "string",
"description": "The specific question, decision, or instruction needed from the human operator.",
},
},
},
{
Name: toolDelegate,
OriginalName: toolDelegate,
@@ -78,6 +92,9 @@ func Builtins(opts ...Option) (tools []ai.Tool, handle func(name string, input m
case toolPlan:
r := a.handlePlan(ai.ToolCall{Name: name, Input: input})
return r.Value, r.Content, true
case toolHumanInput:
r := a.handleHumanInput(ai.ToolCall{Name: name, Input: input})
return r.Value, r.Content, true
case toolDelegate:
r := a.handleDelegate(context.Background(), ai.ToolCall{Name: name, Input: input})
return r.Value, r.Content, true
@@ -97,17 +114,21 @@ func Builtins(opts ...Option) (tools []ai.Tool, handle func(name string, input m
// prevents runaway recursion).
func (a *agentImpl) toolHandler() ai.ToolHandler {
if a.ephemeral {
return a.tools.Handler()
return a.toolTimeoutWrap(a.tools.Handler())
}
// Innermost first: base, then guardrails (approve → loop → step →
// plan), then developer wrappers outermost. Wrapping reverses order,
// so the result runs plan → step → loop → approve → base.
// so the result runs plan → step → loop → approve → checkpoint → base.
h := a.baseHandler()
h = a.toolTimeoutWrap(h)
h = a.toolRetryWrap(h)
h = a.checkpointToolWrap(h)
h = a.approveWrap(h)
h = a.loopWrap(h)
h = a.stepWrap(h)
h = a.planWrap(h)
h = contextWrap(h)
h = a.traceTool(h)
for i := len(a.opts.wrappers) - 1; i >= 0; i-- {
h = a.opts.wrappers[i](h)
@@ -115,6 +136,129 @@ func (a *agentImpl) toolHandler() ai.ToolHandler {
return h
}
// contextWrap stops tool execution promptly when the Ask context has
// already been canceled or its deadline has expired. This keeps guardrail
// bookkeeping and side-effecting tools from running after the caller has
// abandoned the agent run.
func contextWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
select {
case <-ctx.Done():
return errResult(call.ID, ctx.Err().Error())
default:
}
return next(ctx, call)
}
}
// toolTimeoutWrap gives each tool execution its own deadline while preserving
// caller cancellation. Handlers still execute synchronously; tools that honor
// context (custom tools, delegate RPC/A2A, and go-micro RPC clients) return
// promptly with a bounded error result when the deadline expires.
func (a *agentImpl) toolTimeoutWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if a.opts.ToolTimeout <= 0 {
return next(ctx, call)
}
toolCtx, cancel := context.WithTimeout(ctx, a.opts.ToolTimeout)
defer cancel()
return next(toolCtx, call)
}
}
// toolRetryWrap retries transient tool failures with bounded backoff. It is
// opt-in because tools can have side effects; guardrail refusals and caller
// cancellation are never retried.
func (a *agentImpl) toolRetryWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
maxAttempts := a.opts.ToolMaxAttempts
if maxAttempts <= 0 {
maxAttempts = 1
}
var res ai.ToolResult
for attempt := 1; attempt <= maxAttempts; attempt++ {
if err := ctx.Err(); err != nil {
return errResult(call.ID, err.Error())
}
res = next(ctx, call)
if !retryableToolResult(res) || attempt == maxAttempts || ctx.Err() != nil {
return annotateToolAttempts(res, attempt)
}
t := time.NewTimer(toolRetryBackoff(attempt, a.opts.ToolRetryBackoff))
select {
case <-ctx.Done():
if !t.Stop() {
<-t.C
}
return errResult(call.ID, ctx.Err().Error())
case <-t.C:
}
}
return annotateToolAttempts(res, maxAttempts)
}
}
func retryableToolResult(res ai.ToolResult) bool {
if res.Refused != "" {
return false
}
msg := toolErrorMessage(res)
if msg == "" {
return false
}
return ai.IsTransientError(fmt.Errorf("%s", msg))
}
func toolErrorMessage(res ai.ToolResult) string {
if m, ok := res.Value.(map[string]string); ok {
return m["error"]
}
if m, ok := res.Value.(map[string]any); ok {
if v, ok := m["error"].(string); ok {
return v
}
}
var decoded map[string]string
if err := json.Unmarshal([]byte(res.Content), &decoded); err == nil {
return decoded["error"]
}
return ""
}
func annotateToolAttempts(res ai.ToolResult, attempts int) ai.ToolResult {
if attempts <= 1 {
return res
}
res.Attempts = attempts
if m, ok := res.Value.(map[string]string); ok {
cp := map[string]any{}
for k, v := range m {
cp[k] = v
}
cp["attempts"] = attempts
res.Value = cp
if b, err := json.Marshal(cp); err == nil {
res.Content = string(b)
}
}
return res
}
func toolRetryBackoff(attempt int, base time.Duration) time.Duration {
if base <= 0 {
base = 200 * time.Millisecond
}
if shift := attempt - 1; shift > 0 {
base <<= shift
}
if base > 30*time.Second {
return 30 * time.Second
}
return base
}
// baseHandler executes a tool call: a developer custom tool, the built-in
// delegate, or an RPC to the service. It is the innermost handler.
func (a *agentImpl) baseHandler() ai.ToolHandler {
@@ -129,6 +273,9 @@ func (a *agentImpl) baseHandler() ai.ToolHandler {
return ai.ToolResult{ID: call.ID, Value: out, Content: out}
}
}
if call.Name == toolHumanInput {
return a.handleHumanInput(call)
}
if call.Name == toolDelegate {
return a.handleDelegate(ctx, call)
}
@@ -143,7 +290,16 @@ func (a *agentImpl) planWrap(next ai.ToolHandler) ai.ToolHandler {
if call.Name == toolPlan {
return a.handlePlan(call)
}
return next(ctx, call)
if call.Name == toolDelegate {
if blocked := a.unfinishedPlanStepsBeforeDelegation(); len(blocked) > 0 {
return refused(call.ID, ai.RefusedApproval, "complete these plan steps before delegating: "+strings.Join(blocked, ", "))
}
}
res := next(ctx, call)
if res.Refused == "" && toolErrorMessage(res) == "" {
a.completeNextPlanStep()
}
return res
}
}
@@ -184,6 +340,16 @@ func (a *agentImpl) loopWrap(next ai.ToolHandler) ai.ToolHandler {
}
// approveWrap gates each action before it runs (ApproveTool).
type approvalPause struct {
Tool string
Message string
}
type inputPause struct {
OriginalMessage string `json:"original_message"`
Prompt string `json:"prompt"`
}
func (a *agentImpl) approveWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if a.opts.Approve != nil {
@@ -192,6 +358,7 @@ func (a *agentImpl) approveWrap(next ai.ToolHandler) ai.ToolHandler {
if reason != "" {
msg += ": " + reason
}
a.pause = &approvalPause{Tool: call.Name, Message: msg}
return refused(call.ID, ai.RefusedApproval, msg)
}
}
@@ -202,12 +369,215 @@ func (a *agentImpl) approveWrap(next ai.ToolHandler) ai.ToolHandler {
// handlePlan persists the supplied plan to the agent's memory and
// echoes it back so the model can see the stored state.
func (a *agentImpl) handlePlan(call ai.ToolCall) ai.ToolResult {
data, err := json.Marshal(call.Input)
input := preserveCompletedPlanSteps(a.loadPlan(), call.Input)
data, err := json.Marshal(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)}
return ai.ToolResult{ID: call.ID, Value: input, Content: string(data)}
}
func preserveCompletedPlanSteps(stored string, input map[string]any) map[string]any {
if stored == "" {
return input
}
var previous map[string]any
if err := json.Unmarshal([]byte(stored), &previous); err != nil {
return input
}
completed := completedPlanTasks(previous)
if len(completed) == 0 {
return input
}
steps, ok := input["steps"].([]any)
if !ok {
return input
}
for _, raw := range steps {
step, ok := raw.(map[string]any)
if !ok {
continue
}
task, _ := step["task"].(string)
if completed[planTaskCompletionKey(task)] && isUnfinishedPlanStatus(step["status"]) {
step["status"] = "done"
}
}
return input
}
func completedPlanTasks(plan map[string]any) map[string]bool {
steps, ok := plan["steps"].([]any)
if !ok {
return nil
}
completed := map[string]bool{}
for _, raw := range steps {
step, ok := raw.(map[string]any)
if !ok {
continue
}
status, _ := step["status"].(string)
if status != "done" {
continue
}
task, _ := step["task"].(string)
if task = planTaskCompletionKey(task); task != "" {
completed[task] = true
}
}
return completed
}
func normalizePlanTask(task string) string {
return strings.Join(strings.Fields(strings.ToLower(task)), " ")
}
func planTaskCompletionKey(task string) string {
normalized := normalizePlanTask(task)
if normalized == "" {
return ""
}
if isLaunchReadinessDelegationPlanTask(normalized) {
return "launch-readiness-notification"
}
return normalized
}
func isLaunchReadinessDelegationPlanTask(task string) bool {
task = normalizePlanTask(task)
if !strings.Contains(task, "notify") && !strings.Contains(task, "notification") {
return false
}
hasLaunchReadiness := strings.Contains(task, "launch") || strings.Contains(task, "readiness") || strings.Contains(task, "ready")
hasOwnerComms := strings.Contains(task, "owner") && strings.Contains(task, "comms")
return hasLaunchReadiness || hasOwnerComms
}
func isUnfinishedPlanStatus(status any) bool {
s, _ := status.(string)
return s == "" || s == "pending" || s == "in_progress"
}
func (a *agentImpl) completeNextPlanStep() {
plan := a.loadPlan()
if plan == "" {
return
}
var data map[string]any
if err := json.Unmarshal([]byte(plan), &data); err != nil {
return
}
steps, ok := data["steps"].([]any)
if !ok {
return
}
for _, raw := range steps {
step, ok := raw.(map[string]any)
if !ok {
continue
}
status, _ := step["status"].(string)
if status == "" || status == "pending" || status == "in_progress" {
step["status"] = "done"
b, err := json.Marshal(data)
if err == nil {
_ = a.stateStore().Write(&store.Record{Key: planKey, Value: b})
}
return
}
}
}
func (a *agentImpl) unfinishedPlanStepsBeforeDelegation() []string {
plan := a.loadPlan()
if plan == "" {
return nil
}
var data map[string]any
if err := json.Unmarshal([]byte(plan), &data); err != nil {
return nil
}
steps, ok := data["steps"].([]any)
if !ok {
return nil
}
var unfinished []string
for _, raw := range steps {
step, ok := raw.(map[string]any)
if !ok {
continue
}
task := planStepTask(step)
if isDelegationPlanTask(task) {
break
}
if !isUnfinishedPlanStatus(step["status"]) {
continue
}
if task == "" {
task = "<unnamed>"
}
unfinished = append(unfinished, task)
}
return unfinished
}
func planStepTask(step map[string]any) string {
if task, _ := step["task"].(string); task != "" {
return task
}
desc, _ := step["description"].(string)
return desc
}
func isDelegationPlanTask(task string) bool {
task = normalizePlanTask(task)
return strings.Contains(task, "delegate") || strings.Contains(task, "notify") || strings.Contains(task, "notification")
}
func (a *agentImpl) unfinishedPlanSteps() []string {
plan := a.loadPlan()
if plan == "" {
return nil
}
var data map[string]any
if err := json.Unmarshal([]byte(plan), &data); err != nil {
return nil
}
steps, ok := data["steps"].([]any)
if !ok {
return nil
}
var unfinished []string
for _, raw := range steps {
step, ok := raw.(map[string]any)
if !ok {
continue
}
status, _ := step["status"].(string)
if status != "" && status != "pending" && status != "in_progress" {
continue
}
task := planStepTask(step)
if task == "" {
task = "<unnamed>"
}
unfinished = append(unfinished, task)
}
return unfinished
}
// handleHumanInput records that the model needs operator input before it can continue.
func (a *agentImpl) handleHumanInput(call ai.ToolCall) ai.ToolResult {
prompt, _ := call.Input["prompt"].(string)
prompt = strings.TrimSpace(prompt)
if prompt == "" {
prompt = "human input required"
}
a.pause = &approvalPause{Tool: toolHumanInput, Message: prompt}
return refused(call.ID, ai.RefusedApproval, "input-required: "+prompt)
}
// handleDelegate hands a subtask to another agent. Delegate-first:
@@ -261,6 +631,10 @@ func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) ai.Too
WithRegistry(a.opts.Registry),
WithClient(a.opts.Client),
WithStore(a.opts.Store),
ModelCallTimeout(a.opts.ModelTimeout),
ModelRetry(a.opts.ModelMaxAttempts, a.opts.ModelRetryBackoff),
ToolCallTimeout(a.opts.ToolTimeout),
ToolRetry(a.opts.ToolMaxAttempts, a.opts.ToolRetryBackoff),
TraceProvider(a.opts.TraceProvider),
)
// Record lineage so the sub-agent's tool calls carry this run as parent.
+96 -6
View File
@@ -1,6 +1,7 @@
package agent
import (
"context"
"encoding/json"
"testing"
@@ -11,15 +12,15 @@ import (
func TestBuiltinTools(t *testing.T) {
tools := builtinTools()
if len(tools) != 2 {
t.Fatalf("builtinTools() = %d tools, want 2", len(tools))
if len(tools) != 3 {
t.Fatalf("builtinTools() = %d tools, want 3", len(tools))
}
names := map[string]bool{}
for _, tl := range tools {
names[tl.Name] = true
}
if !names[toolPlan] || !names[toolDelegate] {
t.Errorf("builtin tools = %v, want plan and delegate", names)
if !names[toolPlan] || !names[toolDelegate] || !names[toolHumanInput] {
t.Errorf("builtin tools = %v, want plan, request_input, and delegate", names)
}
}
@@ -52,6 +53,53 @@ func TestHandlePlanPersists(t *testing.T) {
}
}
func TestHandlePlanPreservesCompletedSteps(t *testing.T) {
mem := store.NewMemoryStore()
a := New(Name("planner"), WithStore(mem)).(*agentImpl)
a.handlePlan(ai.ToolCall{Name: "plan", Input: map[string]any{
"steps": []any{
map[string]any{"task": "create Design task", "status": "done"},
map[string]any{"task": "Delegate readiness notification to comms agent", "status": "done"},
},
}})
res := a.handlePlan(ai.ToolCall{Name: "plan", Input: map[string]any{
"steps": []any{
map[string]any{"task": "create Design task", "status": "done"},
map[string]any{"task": " delegate readiness notification TO comms agent ", "status": "in_progress"},
map[string]any{"task": "write summary", "status": "pending"},
},
}})
if res.Content == "" {
t.Fatal("handlePlan returned empty content")
}
if unfinished := a.unfinishedPlanSteps(); len(unfinished) != 1 || unfinished[0] != "write summary" {
t.Fatalf("unfinished plan steps = %v, want only write summary", unfinished)
}
}
func TestHandlePlanPreservesCompletedLaunchReadinessNotification(t *testing.T) {
mem := store.NewMemoryStore()
a := New(Name("planner"), WithStore(mem)).(*agentImpl)
a.handlePlan(ai.ToolCall{Name: toolPlan, Input: map[string]any{
"steps": []any{
map[string]any{"task": "notify owner via comms", "status": "done"},
},
}})
a.handlePlan(ai.ToolCall{Name: toolPlan, Input: map[string]any{
"steps": []any{
map[string]any{"task": "Delegate launch readiness notification for owner@acme.com to comms agent", "status": "in_progress"},
},
}})
if unfinished := a.unfinishedPlanSteps(); len(unfinished) != 0 {
t.Fatalf("unfinished plan steps = %v, want launch readiness notification preserved as done", unfinished)
}
}
func TestPlanShowsInPrompt(t *testing.T) {
mem := store.NewMemoryStore()
a := New(Name("planner"), Prompt("base prompt"), WithStore(mem)).(*agentImpl)
@@ -109,8 +157,8 @@ func TestBuiltinsAccessor(t *testing.T) {
WithRegistry(registry.NewMemoryRegistry()),
)
if len(tools) != 2 {
t.Fatalf("Builtins() returned %d tools, want 2", len(tools))
if len(tools) != 3 {
t.Fatalf("Builtins() returned %d tools, want 3", len(tools))
}
// A name that isn't a built-in falls through (ok == false).
@@ -165,3 +213,45 @@ func TestIsAgent(t *testing.T) {
t.Error("isAgent(nonexistent) = true, want false")
}
}
func TestPlanWrapBlocksDelegationUntilPriorPlanStepsFinish(t *testing.T) {
mem := store.NewMemoryStore()
a := New(Name("planner"), WithStore(mem)).(*agentImpl)
a.handlePlan(ai.ToolCall{Name: toolPlan, Input: map[string]any{
"steps": []any{
map[string]any{"task": "Create Design task", "status": "pending"},
map[string]any{"task": "Create Build task", "status": "pending"},
map[string]any{"task": "Create Ship task", "status": "pending"},
map[string]any{"task": "Delegate readiness notification to comms agent", "status": "pending"},
},
}})
called := false
handle := a.planWrap(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
called = true
return ai.ToolResult{ID: call.ID, Content: "ok"}
})
res := handle(context.Background(), ai.ToolCall{ID: "delegate-1", Name: toolDelegate, Input: map[string]any{"to": "comms"}})
if called {
t.Fatal("delegate handler was called before prior task plan steps completed")
}
if res.Refused == "" {
t.Fatalf("delegate result was not refused: %+v", res)
}
if got := res.Content; !containsStr(got, "Create Design task") || !containsStr(got, "Create Ship task") {
t.Fatalf("delegate refusal content = %q, want prior unfinished task steps", got)
}
for _, id := range []string{"add-design", "add-build", "add-ship"} {
_ = handle(context.Background(), ai.ToolCall{ID: id, Name: "task.Add", Input: map[string]any{"title": id}})
}
called = false
res = handle(context.Background(), ai.ToolCall{ID: "delegate-2", Name: toolDelegate, Input: map[string]any{"to": "comms"}})
if !called {
t.Fatal("delegate handler was not called after prior task plan steps completed")
}
if res.Refused != "" {
t.Fatalf("delegate result refused after prior task steps completed: %+v", res)
}
}
+290
View File
@@ -0,0 +1,290 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/flow"
)
const (
agentAskStep = "ask"
agentApprovalStep = "approval"
agentInputStep = "input-required"
)
func (a *agentImpl) newCheckpointRun(runID, message, parentRunID string, existing *flow.Run) flow.Run {
now := time.Now()
run := flow.Run{
ID: runID,
ParentID: parentRunID,
Flow: a.opts.Name,
State: flow.State{Stage: agentAskStep, Data: []byte(message)},
Steps: []flow.StepRecord{{Name: agentAskStep, Status: "in_progress"}},
Status: "running",
Started: now,
Updated: now,
}
if existing != nil {
run = *existing
run.Status = "running"
run.State.Stage = agentAskStep
if len(run.Steps) == 0 {
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
}
run.Steps[0].Status = "in_progress"
run.Steps[0].Error = ""
run.Steps[0].Result = ""
}
return run
}
func (a *agentImpl) saveRun(ctx context.Context, run flow.Run) error {
if a.opts.Checkpoint == nil {
return nil
}
if err := a.opts.Checkpoint.Save(ctx, run); err != nil {
return fmt.Errorf("agent %s checkpoint save: %w", a.opts.Name, err)
}
if info, ok := ai.RunInfoFrom(ctx); ok {
stage := run.State.Stage
if stage == "" && len(run.Steps) > 0 {
stage = run.Steps[0].Name
}
a.recordTimelineEvent(ctx, RunEvent{
Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent,
Kind: "checkpoint", Name: stage, Status: run.Status,
})
}
return nil
}
// Resume returns the response for a checkpointed agent run. Completed runs are
// returned from the checkpoint without calling the model or replaying tool
// calls; failed or in-progress runs continue from the saved input message.
func Resume(ctx context.Context, ag Agent, runID string) (*Response, error) {
a, ok := ag.(*agentImpl)
if !ok {
return nil, fmt.Errorf("agent resume: unsupported agent implementation %T", ag)
}
return a.resume(ctx, runID)
}
func (a *agentImpl) resume(ctx context.Context, runID string) (*Response, error) {
if a.opts.Checkpoint == nil {
return nil, fmt.Errorf("agent %s has no checkpoint configured", a.opts.Name)
}
run, ok, err := a.opts.Checkpoint.Load(ctx, runID)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("agent run %s not found", runID)
}
if run.Status == "paused" {
if run.State.Stage == agentInputStep {
return nil, fmt.Errorf("agent run %s is input-required; resume with ResumeInput", runID)
}
run.Status = "running"
run.State.Stage = agentAskStep
}
if run.Status == "done" {
var resp Response
if err := json.Unmarshal(run.State.Data, &resp); err != nil {
return nil, fmt.Errorf("agent run %s response decode: %w", runID, err)
}
return &resp, nil
}
if terminalAgentRunStatus(run.Status) {
return nil, fmt.Errorf("agent run %s is terminal with status %q", runID, run.Status)
}
message := string(run.State.Data)
parentID := run.ParentID
a.mu.Lock()
defer a.mu.Unlock()
if a.model == nil {
a.setup()
}
return a.askLocked(ctx, run.ID, message, parentID, &run, false)
}
// ResumeInput resumes a checkpointed agent run that paused via the built-in
// request_input tool. The supplied input is appended to the original request so
// the same run can continue with durable checkpoint and completed tool history.
func ResumeInput(ctx context.Context, ag Agent, runID, input string) (*Response, error) {
a, ok := ag.(*agentImpl)
if !ok {
return nil, fmt.Errorf("agent resume input: unsupported agent implementation %T", ag)
}
return a.resumeInput(ctx, runID, input)
}
func (a *agentImpl) resumeInput(ctx context.Context, runID, input string) (*Response, error) {
if a.opts.Checkpoint == nil {
return nil, fmt.Errorf("agent %s has no checkpoint configured", a.opts.Name)
}
run, ok, err := a.opts.Checkpoint.Load(ctx, runID)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("agent run %s not found", runID)
}
if run.Status != "paused" || run.State.Stage != agentInputStep {
return nil, fmt.Errorf("agent run %s is not waiting for human input", runID)
}
var p inputPause
if err := run.State.Scan(&p); err != nil {
return nil, fmt.Errorf("agent run %s input state decode: %w", runID, err)
}
message := p.OriginalMessage
if message == "" {
message = string(run.State.Data)
}
message += "\n\nHuman input: " + input
run.Status = "running"
run.State.Stage = agentAskStep
run.State.Data = []byte(message)
a.mu.Lock()
defer a.mu.Unlock()
if a.model == nil {
a.setup()
}
return a.askLocked(ctx, run.ID, message, run.ParentID, &run, true)
}
func (a *agentImpl) pending(ctx context.Context) ([]flow.Run, error) {
if a.opts.Checkpoint == nil {
return nil, nil
}
runs, err := a.opts.Checkpoint.List(ctx)
if err != nil {
return nil, err
}
out := runs[:0]
for _, run := range runs {
if run.Flow == a.opts.Name && !terminalAgentRunStatus(run.Status) {
out = append(out, run)
}
}
return out, nil
}
func terminalAgentRunStatus(status string) bool {
switch status {
case "done", "canceled", "timeout", "rate_limited", "expired":
return true
default:
return false
}
}
func agentRunFailureStatus(err error) string {
switch ai.ClassifyError(err) {
case ai.ErrorKindCanceled:
return "canceled"
case ai.ErrorKindTimeout:
return "timeout"
case ai.ErrorKindRateLimited:
return "rate_limited"
default:
return "failed"
}
}
type operationalError struct {
err error
hint string
}
func (e *operationalError) Error() string {
if e == nil {
return ""
}
return e.err.Error() + "; " + e.hint
}
func (e *operationalError) Unwrap() error {
if e == nil {
return nil
}
return e.err
}
func agentOperationalError(err error) error {
if err == nil {
return nil
}
switch ai.ClassifyError(err) {
case ai.ErrorKindCanceled:
return &operationalError{err: err, hint: "agent run canceled; inspect run history with `micro inspect agent <name> --status canceled` or see docs/guides/debugging-agents.md"}
case ai.ErrorKindTimeout:
return &operationalError{err: err, hint: "agent provider call timed out; inspect run history with `micro inspect agent <name> --status timeout`, then adjust AgentModelCallTimeout/AgentModelRetry or see docs/guides/debugging-agents.md"}
case ai.ErrorKindRateLimited:
return &operationalError{err: err, hint: "agent provider was rate limited; inspect run history with `micro inspect agent <name> --status rate_limited`, check provider keys with `micro agent preflight`, or see docs/guides/debugging-agents.md"}
case ai.ErrorKindUnavailable:
return &operationalError{err: err, hint: "agent provider appears temporarily unavailable; retry with bounded AgentModelRetry and verify provider setup with `micro agent preflight` or docs/guides/debugging-agents.md"}
default:
return err
}
}
func (a *agentImpl) checkpointToolWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if a.opts.Checkpoint == nil || a.currentRun == nil {
return next(ctx, call)
}
name := toolCheckpointName(call)
if rec, ok := findStep(a.currentRun.Steps, name); ok && rec.Status == "done" {
return ai.ToolResult{ID: call.ID, Value: rec.Result, Content: rec.Result}
}
idx := upsertStep(&a.currentRun.Steps, flow.StepRecord{Name: name, Status: "in_progress"})
_ = a.saveRun(ctx, *a.currentRun)
res := next(ctx, call)
a.currentRun.Steps[idx].Attempts++
if res.Refused != "" {
a.currentRun.Steps[idx].Status = "failed"
a.currentRun.Steps[idx].Error = res.Content
_ = a.saveRun(ctx, *a.currentRun)
return res
}
a.currentRun.Steps[idx].Status = "done"
a.currentRun.Steps[idx].Result = res.Content
a.currentRun.Steps[idx].Error = ""
_ = a.saveRun(ctx, *a.currentRun)
return res
}
}
func toolCheckpointName(call ai.ToolCall) string {
b, _ := json.Marshal(call.Input)
return "tool:" + call.Name + ":" + string(b)
}
func findStep(steps []flow.StepRecord, name string) (flow.StepRecord, bool) {
for _, step := range steps {
if step.Name == name {
return step, true
}
}
return flow.StepRecord{}, false
}
func upsertStep(steps *[]flow.StepRecord, rec flow.StepRecord) int {
for i := range *steps {
if (*steps)[i].Name == rec.Name {
(*steps)[i].Status = rec.Status
(*steps)[i].Error = rec.Error
return i
}
}
if len(*steps) == 0 || (*steps)[0].Name != agentAskStep {
*steps = append([]flow.StepRecord{{Name: agentAskStep, Status: "in_progress"}}, (*steps)...)
}
*steps = append(*steps, rec)
return len(*steps) - 1
}
+680
View File
@@ -0,0 +1,680 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/client"
codecBytes "go-micro.dev/v6/codec/bytes"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
func TestResumeCompletedCheckpointDoesNotReplayModel(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "durable-agent")
calls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
return &ai.Response{Reply: "done"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("durable-agent"), WithCheckpoint(cp))
resp, err := a.Ask(ctx, "finish the work")
if err != nil {
t.Fatalf("Ask: %v", err)
}
if calls != 1 {
t.Fatalf("model calls after Ask = %d, want 1", calls)
}
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
t.Fatal("Resume of a completed run replayed the model")
return nil, nil
}
resumed, err := Resume(ctx, a, resp.RunID)
if err != nil {
t.Fatalf("Resume: %v", err)
}
if resumed.Reply != "done" {
t.Fatalf("resumed reply = %q, want done", resumed.Reply)
}
if resumed.RunID != resp.RunID {
t.Fatalf("resumed run id = %q, want %q", resumed.RunID, resp.RunID)
}
if calls != 1 {
t.Fatalf("model calls after Resume = %d, want 1", calls)
}
}
func TestResumeFailedCheckpointDoesNotReplayCompletedTool(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "tool-resume-agent")
toolRuns := 0
first := true
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler != nil {
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.charge", Input: map[string]any{"order": "42"}})
if res.Content != "charged" {
t.Fatalf("tool result = %q, want charged", res.Content)
}
}
if first {
first = false
return nil, errors.New("model connection dropped after tool")
}
return &ai.Response{Reply: "finished from checkpoint"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("tool-resume-agent"), WithCheckpoint(cp),
WithTool("external.charge", "charge once", nil, func(context.Context, map[string]any) (string, error) {
toolRuns++
return "charged", nil
}))
_, err := a.Ask(ctx, "charge order 42")
if err == nil {
t.Fatal("Ask succeeded, want simulated failure")
}
if toolRuns != 1 {
t.Fatalf("tool executions after failed Ask = %d, want 1", toolRuns)
}
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 {
t.Fatalf("Pending returned %d runs, want 1", len(runs))
}
resp, err := Resume(ctx, a, runs[0].ID)
if err != nil {
t.Fatalf("Resume: %v", err)
}
if resp.Reply != "finished from checkpoint" {
t.Fatalf("Resume reply = %q", resp.Reply)
}
if toolRuns != 1 {
t.Fatalf("tool executions after Resume = %d, want completed tool was not replayed", toolRuns)
}
}
func TestCheckpointSkipsDuplicateToolWithinAsk(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "tool-dedupe-agent")
toolRuns := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
t.Fatal("missing tool handler")
}
opts.ToolHandler(ctx, ai.ToolCall{ID: "plan-1", Name: toolPlan, Input: map[string]any{
"steps": []any{
map[string]any{"task": "create Design task", "status": "pending"},
},
}})
for i := 0; i < 3; i++ {
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.create", Input: map[string]any{"title": "Design"}})
if res.Content != "created Design" {
t.Fatalf("tool result %d = %q, want cached created Design", i, res.Content)
}
}
return &ai.Response{Reply: "done"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("tool-dedupe-agent"), WithCheckpoint(cp),
WithTool("external.create", "create once", nil, func(context.Context, map[string]any) (string, error) {
toolRuns++
return "created Design", nil
}))
if _, err := a.Ask(ctx, "create Design once"); err != nil {
t.Fatalf("Ask: %v", err)
}
if toolRuns != 1 {
t.Fatalf("tool executions = %d, want duplicate calls within the run replayed from checkpoint", toolRuns)
}
if plan := a.loadPlan(); !strings.Contains(plan, `"status":"done"`) {
t.Fatalf("plan = %s, want completed action marked done", plan)
}
}
func TestCheckpointContinuesRunWithUnfinishedPlanStep(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "unfinished-plan-agent")
reg := registry.NewMemoryRegistry()
if err := reg.Register(&registry.Service{
Name: "comms",
Metadata: map[string]string{"type": "agent"},
Nodes: []*registry.Node{{Id: "comms-1", Address: "127.0.0.1:0"}},
}); err != nil {
t.Fatalf("register comms agent: %v", err)
}
delegateCalls := 0
fc := &fakeClient{Client: client.DefaultClient}
fc.callFn = func(ctx context.Context, req client.Request, rsp interface{}) error {
delegateCalls++
if req.Service() != "comms" || req.Endpoint() != "Agent.Chat" {
t.Fatalf("delegate RPC = %s %s, want comms Agent.Chat", req.Service(), req.Endpoint())
}
frame := rsp.(*codecBytes.Frame)
frame.Data = []byte(`{"reply":"owner notified","agent":"comms"}`)
return nil
}
modelCalls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
modelCalls++
if opts.ToolHandler == nil {
t.Fatal("missing tool handler")
}
switch modelCalls {
case 1:
opts.ToolHandler(ctx, ai.ToolCall{ID: "plan-1", Name: toolPlan, Input: map[string]any{
"steps": []any{
map[string]any{"task": "create launch tasks", "status": "done"},
map[string]any{"task": "delegate readiness notification to comms", "status": "in_progress"},
},
}})
return &ai.Response{Reply: "tasks are ready"}, nil
case 2:
if !strings.Contains(req.Prompt, "delegate readiness notification to comms") {
t.Fatalf("continuation prompt = %q, want unfinished step", req.Prompt)
}
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "delegate-1", Name: toolDelegate, Input: map[string]any{"task": "Notify owner@acme.com that the launch plan is ready", "to": "comms"}})
if !strings.Contains(res.Content, "owner notified") {
t.Fatalf("delegate result = %q, want owner notified", res.Content)
}
return &ai.Response{Reply: "all done"}, nil
default:
t.Fatalf("unexpected model call %d", modelCalls)
return nil, nil
}
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("unfinished-plan-agent"), WithCheckpoint(cp), WithRegistry(reg), WithClient(fc))
resp, err := a.Ask(ctx, "create tasks and notify owner")
if err != nil {
t.Fatalf("Ask: %v", err)
}
if resp.Reply != "all done" {
t.Fatalf("reply = %q, want final continuation reply", resp.Reply)
}
if modelCalls != 2 {
t.Fatalf("model calls = %d, want initial plus continuation", modelCalls)
}
if delegateCalls != 1 {
t.Fatalf("delegate calls = %d, want exactly one", delegateCalls)
}
if unfinished := a.unfinishedPlanSteps(); len(unfinished) != 0 {
t.Fatalf("unfinished plan steps = %v, want none", unfinished)
}
}
func TestCheckpointContinuesRunThroughSeveralSingleStepTurns(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "single-step-plan-agent")
completed := []string{}
modelCalls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
modelCalls++
if opts.ToolHandler == nil {
t.Fatal("missing tool handler")
}
switch modelCalls {
case 1:
opts.ToolHandler(ctx, ai.ToolCall{ID: "plan-1", Name: toolPlan, Input: map[string]any{
"steps": []any{
map[string]any{"task": "create Design task", "status": "pending"},
map[string]any{"task": "create Build task", "status": "pending"},
map[string]any{"task": "create Ship task", "status": "pending"},
map[string]any{"task": "delegate readiness notification", "status": "pending"},
},
}})
return &ai.Response{Reply: "planned"}, nil
case 2, 3, 4, 5:
want := []string{"create Design task", "create Build task", "create Ship task", "delegate readiness notification"}[modelCalls-2]
if !strings.Contains(req.Prompt, want) {
t.Fatalf("continuation prompt %d = %q, want %q", modelCalls, req.Prompt, want)
}
res := opts.ToolHandler(ctx, ai.ToolCall{ID: want, Name: "external.step", Input: map[string]any{"step": want}})
if res.Content != "completed "+want {
t.Fatalf("tool result = %q, want completed %s", res.Content, want)
}
if modelCalls == 5 {
return &ai.Response{Reply: "all plan steps complete"}, nil
}
return &ai.Response{Reply: "one more step complete"}, nil
default:
t.Fatalf("unexpected model call %d", modelCalls)
return nil, nil
}
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("single-step-plan-agent"), WithCheckpoint(cp),
WithTool("external.step", "complete one planned step", nil, func(ctx context.Context, input map[string]any) (string, error) {
step, _ := input["step"].(string)
completed = append(completed, step)
return "completed " + step, nil
}))
resp, err := a.Ask(ctx, "work through the launch plan")
if err != nil {
t.Fatalf("Ask: %v", err)
}
if resp.Reply != "all plan steps complete" {
t.Fatalf("reply = %q, want final continuation reply", resp.Reply)
}
if modelCalls != 5 {
t.Fatalf("model calls = %d, want initial plus four continuations", modelCalls)
}
if len(completed) != 4 {
t.Fatalf("completed steps = %v, want four tool-backed continuations", completed)
}
if unfinished := a.unfinishedPlanSteps(); len(unfinished) != 0 {
t.Fatalf("unfinished plan steps = %v, want none", unfinished)
}
}
func TestResumeFailedCheckpointAfterFreshAgentRestart(t *testing.T) {
ctx := context.Background()
st := store.NewMemoryStore()
cp := flow.StoreCheckpoint(st, "restart-resume-agent")
toolRuns := 0
modelCalls := 0
failFirst := true
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
modelCalls++
if opts.ToolHandler != nil {
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.provision", Input: map[string]any{"service": "api"}})
if res.Content != "provisioned" {
t.Fatalf("tool result = %q, want provisioned", res.Content)
}
}
if failFirst {
failFirst = false
return nil, errors.New("process stopped after tool checkpoint")
}
return &ai.Response{Reply: "resumed after restart"}, nil
}
defer func() { fakeGen = nil }()
newAgent := func() *agentImpl {
return newTestAgent(Name("restart-resume-agent"), WithStore(st), WithCheckpoint(cp),
WithTool("external.provision", "provision service once", nil, func(context.Context, map[string]any) (string, error) {
toolRuns++
return "provisioned", nil
}))
}
first := newAgent()
_, err := first.Ask(ctx, "provision api")
if err == nil {
t.Fatal("Ask succeeded, want simulated process stop")
}
if toolRuns != 1 {
t.Fatalf("tool executions after failed Ask = %d, want 1", toolRuns)
}
runs, err := Pending(ctx, first)
if err != nil {
t.Fatalf("Pending before restart: %v", err)
}
if len(runs) != 1 {
t.Fatalf("Pending before restart returned %d runs, want 1", len(runs))
}
summaries, err := ListRunSummaries(st, "restart-resume-agent")
if err != nil {
t.Fatalf("ListRunSummaries before restart: %v", err)
}
if len(summaries) != 1 {
t.Fatalf("run summaries before restart = %d, want 1", len(summaries))
}
if summaries[0].RunID != runs[0].ID || summaries[0].Status != "error" || summaries[0].Checkpoint != "failed" || summaries[0].Stage != agentAskStep {
t.Fatalf("summary before restart = %#v, want failed ask checkpoint for %s", summaries[0], runs[0].ID)
}
if summaries[0].Events < 4 || summaries[0].LastError == "" {
t.Fatalf("summary before restart lacks debug history/error: %#v", summaries[0])
}
restarted := newAgent()
resp, err := Resume(ctx, restarted, runs[0].ID)
if err != nil {
t.Fatalf("Resume after restart: %v", err)
}
if resp.Reply != "resumed after restart" || resp.RunID != runs[0].ID {
t.Fatalf("response = %#v, want resumed reply on original run id", resp)
}
if toolRuns != 1 {
t.Fatalf("tool executions after restart resume = %d, want checkpointed tool not replayed", toolRuns)
}
if modelCalls != 2 {
t.Fatalf("model calls = %d, want initial call plus resumed call", modelCalls)
}
loaded, ok, err := cp.Load(ctx, runs[0].ID)
if err != nil || !ok {
t.Fatalf("Load resumed run ok=%v err=%v", ok, err)
}
if loaded.Status != "done" || loaded.ParentID != runs[0].ParentID {
t.Fatalf("loaded run status/parent = %s/%s, want done/%s", loaded.Status, loaded.ParentID, runs[0].ParentID)
}
summaries, err = ListRunSummaries(st, "restart-resume-agent")
if err != nil {
t.Fatalf("ListRunSummaries after restart: %v", err)
}
if len(summaries) != 1 {
t.Fatalf("run summaries after restart = %d, want 1", len(summaries))
}
if summaries[0].RunID != runs[0].ID || summaries[0].Status != "done" || summaries[0].Checkpoint != "done" || summaries[0].Stage != agentAskStep {
t.Fatalf("summary after restart = %#v, want done ask checkpoint for %s", summaries[0], runs[0].ID)
}
if summaries[0].Events < 7 {
t.Fatalf("summary after restart recorded %d events, want durable failure/resume/done history", summaries[0].Events)
}
events, err := LoadRunEvents(st, "restart-resume-agent", runs[0].ID)
if err != nil {
t.Fatalf("LoadRunEvents after restart: %v", err)
}
seen := map[string]bool{"run": false, "tool": false, "checkpoint": false, "error": false, "resume": false, "done": false}
for _, e := range events {
if _, ok := seen[e.Kind]; ok {
seen[e.Kind] = true
}
}
for kind, ok := range seen {
if !ok {
t.Fatalf("events after restart missing %s: %#v", kind, events)
}
}
}
func TestResumeFailedCheckpointDoesNotDuplicateCompactedMemory(t *testing.T) {
ctx := context.Background()
st := store.NewMemoryStore()
cp := flow.StoreCheckpoint(st, "memory-resume-agent")
failRetry := true
var sawRecall bool
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
for _, msg := range req.Messages {
if text, ok := msg.Content.(string); ok && strings.Contains(text, "alpha code is 42") {
sawRecall = true
}
}
if strings.Contains(req.Prompt, "use alpha code") && failRetry {
failRetry = false
return nil, errors.New("model connection dropped")
}
return &ai.Response{Reply: "ok"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("memory-resume-agent"), WithStore(st), WithCheckpoint(cp), CompactMemory(4, 1), MemoryRecallLimit(2))
for _, msg := range []string{"alpha code is 42", "beta note", "gamma note"} {
if _, err := a.Ask(ctx, msg); err != nil {
t.Fatalf("Ask(%q): %v", msg, err)
}
}
_, err := a.Ask(ctx, "use alpha code now")
if err == nil {
t.Fatal("Ask succeeded, want simulated provider failure")
}
if got := countMemoryContent(a.mem.Messages(), "use alpha code now"); got != 1 {
t.Fatalf("failed Ask stored prompt %d times, want 1", got)
}
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 {
t.Fatalf("Pending returned %d runs, want 1", len(runs))
}
if _, err := Resume(ctx, a, runs[0].ID); err != nil {
t.Fatalf("Resume: %v", err)
}
if got := countMemoryContent(a.mem.Messages(), "use alpha code now"); got != 1 {
t.Fatalf("resumed failed Ask stored prompt %d times, want no duplicate", got)
}
if !sawRecall {
t.Fatal("resume did not retrieve archived compacted memory")
}
if got := len(a.mem.Messages()); got > 4 {
t.Fatalf("compacted memory retained %d messages after resume, want <= 4", got)
}
}
func countMemoryContent(messages []ai.Message, needle string) int {
var count int
for _, msg := range messages {
if text, ok := msg.Content.(string); ok && strings.Contains(text, needle) {
count++
}
}
return count
}
func TestPendingReturnsUnfinishedAgentRuns(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "pending-agent")
run := flow.Run{ID: "run-1", Flow: "pending-agent", Status: "failed", State: flow.State{Stage: agentAskStep, Data: []byte("retry me")}}
if err := cp.Save(ctx, run); err != nil {
t.Fatalf("Save: %v", err)
}
a := newTestAgent(Name("pending-agent"), WithCheckpoint(cp))
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 || runs[0].ID != "run-1" {
t.Fatalf("Pending = %#v, want run-1", runs)
}
}
func TestPendingSkipsTerminalCanceledAndExpiredAgentRuns(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "terminal-agent")
for _, run := range []flow.Run{
{ID: "active", Flow: "terminal-agent", Status: "failed", State: flow.State{Stage: agentAskStep, Data: []byte("retry me")}},
{ID: "done", Flow: "terminal-agent", Status: "done", State: flow.State{Stage: agentAskStep, Data: []byte("done")}},
{ID: "canceled", Flow: "terminal-agent", Status: "canceled", State: flow.State{Stage: agentAskStep, Data: []byte("canceled")}},
{ID: "expired", Flow: "terminal-agent", Status: "expired", State: flow.State{Stage: agentAskStep, Data: []byte("expired")}},
} {
if err := cp.Save(ctx, run); err != nil {
t.Fatalf("Save(%s): %v", run.ID, err)
}
}
a := newTestAgent(Name("terminal-agent"), WithCheckpoint(cp))
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 || runs[0].ID != "active" {
t.Fatalf("Pending = %#v, want only active failed run", runs)
}
for _, id := range []string{"canceled", "expired"} {
if _, err := Resume(ctx, a, id); err == nil || !strings.Contains(err.Error(), "terminal") {
t.Fatalf("Resume(%s) err = %v, want terminal status error", id, err)
}
}
}
func TestHumanInputPauseResumesSameRunWithInput(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "input-agent")
calls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
if calls == 1 {
if opts.ToolHandler != nil {
opts.ToolHandler(ctx, ai.ToolCall{ID: "input-1", Name: toolHumanInput, Input: map[string]any{"prompt": "Which region should I deploy to?"}})
}
return &ai.Response{Reply: "waiting"}, nil
}
if !strings.Contains(req.Prompt, "Human input: us-east-1") {
t.Fatalf("resumed prompt = %q, want human input", req.Prompt)
}
return &ai.Response{Reply: "deploying to us-east-1"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("input-agent"), WithCheckpoint(cp))
_, err := a.Ask(ctx, "deploy the service")
if err == nil {
t.Fatal("Ask succeeded, want input-required pause")
}
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 || runs[0].Status != "paused" || runs[0].State.Stage != agentInputStep {
t.Fatalf("paused runs = %#v, want one input-required run", runs)
}
var pause inputPause
if err := runs[0].State.Scan(&pause); err != nil {
t.Fatalf("Scan pause: %v", err)
}
if pause.OriginalMessage != "deploy the service" || pause.Prompt != "Which region should I deploy to?" {
t.Fatalf("pause = %#v", pause)
}
if _, err := Resume(ctx, a, runs[0].ID); err == nil || !strings.Contains(err.Error(), "ResumeInput") {
t.Fatalf("Resume input-required err = %v, want guidance", err)
}
resp, err := ResumeInput(ctx, a, runs[0].ID, "us-east-1")
if err != nil {
t.Fatalf("ResumeInput: %v", err)
}
if resp.RunID != runs[0].ID || resp.Reply != "deploying to us-east-1" {
t.Fatalf("response = %#v", resp)
}
loaded, ok, err := cp.Load(ctx, runs[0].ID)
if err != nil || !ok {
t.Fatalf("Load resumed run ok=%v err=%v", ok, err)
}
if loaded.Status != "done" {
t.Fatalf("resumed run status = %q, want done", loaded.Status)
}
}
func TestHumanInputResumeHonorsCanceledContextAndLeavesRunPending(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "input-cancel-agent")
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler != nil {
opts.ToolHandler(ctx, ai.ToolCall{ID: "input-1", Name: toolHumanInput, Input: map[string]any{"prompt": "Approve deploy?"}})
}
return &ai.Response{Reply: "waiting"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("input-cancel-agent"), WithCheckpoint(cp))
if _, err := a.Ask(ctx, "deploy the service"); err == nil {
t.Fatal("Ask succeeded, want input-required pause")
}
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 {
t.Fatalf("Pending returned %d runs, want 1: %#v", len(runs), runs)
}
canceled, cancel := context.WithCancel(ctx)
cancel()
if _, err := ResumeInput(canceled, a, runs[0].ID, "yes"); !errors.Is(err, context.Canceled) {
t.Fatalf("ResumeInput canceled err = %v, want context.Canceled", err)
}
loaded, ok, err := cp.Load(ctx, runs[0].ID)
if err != nil || !ok {
t.Fatalf("Load paused run ok=%v err=%v", ok, err)
}
if loaded.Status != "paused" || loaded.State.Stage != agentInputStep {
t.Fatalf("run status/stage after canceled resume = %s/%s, want paused/%s", loaded.Status, loaded.State.Stage, agentInputStep)
}
var pause inputPause
if err := loaded.State.Scan(&pause); err != nil {
t.Fatalf("Scan pause after canceled resume: %v", err)
}
if pause.OriginalMessage != "deploy the service" || pause.Prompt != "Approve deploy?" {
t.Fatalf("pause after canceled resume = %#v", pause)
}
}
func TestApprovalDenialPausesCheckpointedRunAndResumeContinues(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "approval-agent")
calls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
if opts.ToolHandler != nil {
opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.approve", Input: map[string]any{"id": "42"}})
}
return &ai.Response{Reply: "model saw approval result"}, nil
}
defer func() { fakeGen = nil }()
approved := false
a := newTestAgent(Name("approval-agent"), WithCheckpoint(cp),
WithTool("external.approve", "guarded external action", nil, func(context.Context, map[string]any) (string, error) { return "ok", nil }),
ApproveTool(func(tool string, input map[string]any) (bool, string) {
return approved, "waiting for operator"
}))
_, err := a.Ask(ctx, "send the guarded update")
if err == nil {
t.Fatal("Ask succeeded, want paused approval error")
}
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 {
t.Fatalf("Pending returned %d runs, want 1: %#v", len(runs), runs)
}
if runs[0].Status != "paused" || runs[0].State.Stage != agentApprovalStep {
t.Fatalf("run status/stage = %s/%s, want paused/%s", runs[0].Status, runs[0].State.Stage, agentApprovalStep)
}
if got := string(runs[0].State.Data); got != "send the guarded update" {
t.Fatalf("paused run data = %q", got)
}
approved = true
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
if opts.ToolHandler != nil {
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-2", Name: "external.approve", Input: map[string]any{"id": "42"}})
if res.Refused != "" {
t.Fatalf("resumed call was refused: %#v", res)
}
}
return &ai.Response{Reply: "done after approval"}, nil
}
resp, err := Resume(ctx, a, runs[0].ID)
if err != nil {
t.Fatalf("Resume: %v", err)
}
if resp.Reply != "done after approval" {
t.Fatalf("Resume reply = %q", resp.Reply)
}
loaded, ok, err := cp.Load(ctx, runs[0].ID)
if err != nil || !ok {
t.Fatalf("Load resumed run ok=%v err=%v", ok, err)
}
if loaded.Status != "done" {
t.Fatalf("resumed run status = %q, want done", loaded.Status)
}
if calls != 2 {
t.Fatalf("model calls = %d, want 2", calls)
}
}
+666
View File
@@ -0,0 +1,666 @@
package agent
import (
"context"
"errors"
"fmt"
"os"
"strings"
"testing"
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
type conformanceProvider struct {
name string
model string
key string
live bool
}
func TestAgentProviderConformanceMatrix(t *testing.T) {
providers := []conformanceProvider{
{name: "fake"},
{name: "openai", key: "OPENAI_API_KEY", model: "GO_MICRO_CONFORMANCE_OPENAI_MODEL", live: true},
{name: "anthropic", key: "ANTHROPIC_API_KEY", model: "GO_MICRO_CONFORMANCE_ANTHROPIC_MODEL", live: true},
{name: "atlascloud", key: "ATLASCLOUD_API_KEY", model: "GO_MICRO_CONFORMANCE_ATLASCLOUD_MODEL", live: true},
{name: "gemini", key: "GEMINI_API_KEY", model: "GO_MICRO_CONFORMANCE_GEMINI_MODEL", live: true},
{name: "groq", key: "GROQ_API_KEY", model: "GO_MICRO_CONFORMANCE_GROQ_MODEL", live: true},
{name: "mistral", key: "MISTRAL_API_KEY", model: "GO_MICRO_CONFORMANCE_MISTRAL_MODEL", live: true},
{name: "together", key: "TOGETHER_API_KEY", model: "GO_MICRO_CONFORMANCE_TOGETHER_MODEL", live: true},
}
selected := selectedConformanceProviders(os.Getenv("GO_MICRO_AGENT_CONFORMANCE_PROVIDERS"))
for _, provider := range providers {
provider := provider
if len(selected) > 0 && !selected[provider.name] {
continue
}
t.Run(provider.name, func(t *testing.T) {
runAgentConformanceScenario(t, provider)
})
}
}
func selectedConformanceProviders(csv string) map[string]bool {
out := map[string]bool{}
for _, part := range strings.Split(csv, ",") {
part = strings.TrimSpace(part)
if part != "" {
out[part] = true
}
}
return out
}
func runAgentConformanceScenario(t *testing.T, provider conformanceProvider) {
t.Helper()
if provider.live {
if os.Getenv(provider.key) == "" {
t.Skipf("%s not set; skipping live %s conformance", provider.key, provider.name)
}
if os.Getenv("GO_MICRO_AGENT_CONFORMANCE_LIVE") == "" {
t.Skipf("GO_MICRO_AGENT_CONFORMANCE_LIVE not set; skipping live %s conformance", provider.name)
}
} else {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if err := validateConformanceRequest(req, opts); err != nil {
return nil, err
}
plan := opts.ToolHandler(ctx, ai.ToolCall{
ID: "fake-plan-1",
Name: "plan",
Input: map[string]any{"steps": []map[string]any{
{"description": "call conformance_echo", "status": "pending"},
{"description": "attempt guarded delegate", "status": "pending"},
}},
})
echo := opts.ToolHandler(ctx, ai.ToolCall{
ID: "fake-call-1",
Name: "conformance_echo",
Input: map[string]any{"value": "agent-conformance"},
})
delegate := opts.ToolHandler(ctx, ai.ToolCall{
ID: "fake-delegate-1",
Name: "delegate",
Input: map[string]any{"task": "summarize the conformance marker", "to": "blocked-reviewer"},
})
if plan.Content == "" {
return nil, errors.New("empty plan result")
}
if echo.Content == "" {
return nil, errors.New("empty tool result")
}
if delegate.Refused != ai.RefusedApproval {
return nil, fmt.Errorf("delegate refusal = %q, want %q", delegate.Refused, ai.RefusedApproval)
}
return &ai.Response{
Reply: "planned, called conformance_echo, and handled guarded delegate refusal",
Answer: echo.Content + " " + delegate.Content,
ToolCalls: []ai.ToolCall{
{ID: "fake-plan-1", Name: "plan", Input: map[string]any{}},
{ID: "fake-call-1", Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: echo.Content},
{ID: "fake-delegate-1", Name: "delegate", Input: map[string]any{"task": "summarize the conformance marker", "to": "blocked-reviewer"}, Error: delegate.Content},
},
}, nil
}
defer func() { fakeGen = nil }()
}
var sawTool bool
var sawRunInfo bool
var sawBlockedDelegate bool
agentOpts := []Option{
Name("conformance-" + provider.name),
Provider(provider.name),
APIKey(os.Getenv(provider.key)),
Prompt(conformanceSystemPrompt(provider.name)),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WithMemory(NewInMemory(8)),
ModelCallTimeout(45 * time.Second),
ApproveTool(func(tool string, input map[string]any) (bool, string) {
if tool == "delegate" {
sawBlockedDelegate = true
return false, "cross-provider conformance blocks delegate side effects"
}
return true, ""
}),
WithTool("conformance_echo", "Echo a conformance value and return a deterministic marker.", map[string]any{
"value": map[string]any{"type": "string", "description": "value to echo"},
}, func(ctx context.Context, input map[string]any) (string, error) {
sawTool = true
info, ok := ai.RunInfoFrom(ctx)
if !ok {
return "", errors.New("missing run info")
}
if info.RunID == "" || info.Agent != "conformance-"+provider.name {
return "", fmt.Errorf("unexpected run info: %+v", info)
}
sawRunInfo = true
if input["value"] != "agent-conformance" {
return "", fmt.Errorf("unexpected value %v", input["value"])
}
return `{"marker":"agent-conformance-ok"}`, nil
}),
}
if provider.model != "" {
if model := os.Getenv(provider.model); model != "" {
agentOpts = append(agentOpts, Model(model))
}
}
a := New(agentOpts...)
resp, err := askWithConformanceRetry(context.Background(), a, "Run the provider conformance check.", &sawTool, &sawBlockedDelegate)
if err != nil {
t.Fatalf("Ask: %v", err)
}
if resp.RunID == "" {
t.Fatal("RunID is empty")
}
if resp.Agent != "conformance-"+provider.name {
t.Fatalf("Agent = %q", resp.Agent)
}
if !sawTool {
t.Fatal("provider did not request the conformance tool")
}
if !sawRunInfo {
t.Fatal("tool did not receive RunInfo")
}
if !sawBlockedDelegate {
t.Fatal("provider did not exercise the guarded delegate path")
}
if !strings.Contains(resp.Reply, "agent-conformance-ok") && !strings.Contains(resp.Reply, "agent-conformance") {
t.Fatalf("reply %q does not include conformance marker", resp.Reply)
}
}
func askWithConformanceRetry(ctx context.Context, a Agent, initialPrompt string, sawTool, sawBlockedDelegate *bool) (*Response, error) {
const maxAttempts = 3
prompt := initialPrompt
var resp *Response
for attempt := 1; attempt <= maxAttempts; attempt++ {
var err error
resp, err = a.Ask(ctx, prompt)
if err != nil {
return nil, err
}
sawRequiredTool := sawTool == nil || *sawTool
sawRequiredDelegate := sawBlockedDelegate == nil || *sawBlockedDelegate
hasMarker := responseHasConformanceMarker(resp)
if sawRequiredTool && sawRequiredDelegate && hasMarker {
return resp, nil
}
if attempt == maxAttempts {
break
}
prompt = nextConformanceRetryPrompt(sawRequiredTool, sawRequiredDelegate, hasMarker)
}
missing := missingConformanceRequirements(sawTool, sawBlockedDelegate, responseHasConformanceMarker(resp))
if len(missing) > 0 {
return resp, fmt.Errorf("provider conformance incomplete after %d attempts: missing %s", maxAttempts, strings.Join(missing, ", "))
}
return resp, nil
}
func askWithConformanceToolRetry(ctx context.Context, a Agent, initialPrompt string, sawTool *bool) (*Response, error) {
return askWithConformanceRetry(ctx, a, initialPrompt, sawTool, nil)
}
func missingConformanceRequirements(sawTool, sawBlockedDelegate *bool, hasMarker bool) []string {
var missing []string
if sawTool != nil && !*sawTool {
missing = append(missing, "conformance_echo")
}
if sawBlockedDelegate != nil && !*sawBlockedDelegate {
missing = append(missing, "guarded delegate")
}
if !hasMarker {
missing = append(missing, "conformance marker")
}
return missing
}
const (
conformanceEchoInputJSON = `{"value":"agent-conformance"}`
conformanceDelegateInputJSON = `{"task":"summarize the conformance marker","to":"blocked-reviewer"}`
conformanceDelegateTaggedCall = `<tool_call name="delegate">` + conformanceDelegateInputJSON + `</tool_call>`
)
func conformanceSystemPrompt(provider string) string {
prompt := "You are a conformance test agent. Create a short plan, use conformance_echo exactly once with input " + conformanceEchoInputJSON + ", then attempt to delegate a summary to blocked-reviewer with input " + conformanceDelegateInputJSON + ". You must complete both tool calls before any final answer; a final answer that only mentions the steps without calling both tools is invalid. If the delegate is refused, explain the refusal and answer with the echo result."
if provider == "atlascloud" {
prompt += " AtlasCloud/minimax conformance note: the delegate attempt is mandatory after conformance_echo. If native tool_calls are unavailable, emit the delegate as " + conformanceDelegateTaggedCall + " rather than answering in prose."
}
return prompt
}
func TestAgentProviderConformanceAtlasCloudPromptRequiresTaggedDelegateFallback(t *testing.T) {
prompt := conformanceSystemPrompt("atlascloud")
for _, want := range []string{
"delegate attempt is mandatory",
"You must complete both tool calls before any final answer",
"<tool_call name=\"delegate\">",
`{"task":"summarize the conformance marker","to":"blocked-reviewer"}`,
} {
if !strings.Contains(prompt, want) {
t.Fatalf("atlascloud conformance prompt %q missing %q", prompt, want)
}
}
if strings.Contains(conformanceSystemPrompt("openai"), "AtlasCloud/minimax") {
t.Fatal("non-AtlasCloud prompt should not include provider-specific fallback guidance")
}
}
func TestAgentProviderConformanceRetryPromptsRequireBothTools(t *testing.T) {
for name, prompt := range map[string]string{
"missing tool": nextConformanceRetryPrompt(false, false, false),
"missing delegate": nextConformanceRetryPrompt(true, false, true),
} {
for _, want := range []string{
"delegate exactly once",
conformanceDelegateTaggedCall,
"do not",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("%s retry prompt %q missing %q", name, prompt, want)
}
}
}
}
func nextConformanceRetryPrompt(sawTool, sawBlockedDelegate, hasMarker bool) string {
switch {
case !sawTool:
return "The previous response did not call the required conformance_echo tool. Retry the same conformance check now: first call conformance_echo exactly once with input " + conformanceEchoInputJSON + ", then call delegate exactly once with input " + conformanceDelegateInputJSON + "; do not provide a final answer until both tool calls have been attempted. If native delegate tool_calls are unavailable after conformance_echo, emit exactly " + conformanceDelegateTaggedCall + ". The delegate is expected to be refused by policy; include that refusal and the agent-conformance marker in the final answer."
case !sawBlockedDelegate:
return "The previous response called conformance_echo but did not attempt the required guarded delegation. Continue the same conformance check now: call delegate exactly once with input " + conformanceDelegateInputJSON + "; do not answer in prose until that delegate call has been attempted. If native tool_calls are unavailable, emit exactly " + conformanceDelegateTaggedCall + ". The delegate is expected to be refused by policy; include that refusal and the agent-conformance marker in the final answer."
case !hasMarker:
return "The previous response completed the required tool calls but omitted the conformance marker. Continue the same conformance check now: do not call more tools; answer with the prior echo result marker agent-conformance-ok and mention the guarded delegate refusal."
default:
return "Retry the provider conformance check and include the agent-conformance marker in the final answer."
}
}
func responseHasConformanceMarker(resp *Response) bool {
if resp == nil {
return false
}
return strings.Contains(resp.Reply, "agent-conformance-ok") || strings.Contains(resp.Reply, "agent-conformance")
}
func validateConformanceRequest(req *ai.Request, opts ai.Options) error {
if req.Prompt == "" {
return errors.New("missing prompt")
}
if len(req.Messages) == 0 || req.Messages[len(req.Messages)-1].Role != "user" {
return fmt.Errorf("missing user history: %+v", req.Messages)
}
if len(req.Tools) == 0 {
return errors.New("missing tools")
}
if opts.ToolHandler == nil {
return errors.New("missing tool handler")
}
return nil
}
func TestAgentProviderConformanceFakeError(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
return nil, errors.New("conformance provider failure")
}
defer func() { fakeGen = nil }()
a := New(
Name("conformance-error"),
Provider("fake"),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WithMemory(NewInMemory(4)),
)
_, err := a.Ask(context.Background(), "fail deterministically")
if err == nil || !strings.Contains(err.Error(), "conformance provider failure") {
t.Fatalf("Ask error = %v, want conformance provider failure", err)
}
}
func TestAgentProviderConformanceRetriesMissingTool(t *testing.T) {
var attempts int
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
attempts++
if err := validateConformanceRequest(req, opts); err != nil {
return nil, err
}
if attempts == 1 {
return &ai.Response{Reply: "I can confirm agent-conformance in prose only."}, nil
}
echo := opts.ToolHandler(ctx, ai.ToolCall{
ID: "fake-call-1",
Name: "conformance_echo",
Input: map[string]any{"value": "agent-conformance"},
})
return &ai.Response{
Reply: "called conformance_echo",
Answer: echo.Content,
ToolCalls: []ai.ToolCall{
{ID: "fake-call-1", Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: echo.Content},
},
}, nil
}
defer func() { fakeGen = nil }()
var sawTool bool
a := New(
Name("conformance-retry"),
Provider("fake"),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WithMemory(NewInMemory(4)),
WithTool("conformance_echo", "Echo a conformance value.", map[string]any{
"value": map[string]any{"type": "string"},
}, func(ctx context.Context, input map[string]any) (string, error) {
sawTool = true
return `{"marker":"agent-conformance-ok"}`, nil
}),
)
resp, err := askWithConformanceToolRetry(context.Background(), a, "Run the provider conformance check.", &sawTool)
if err != nil {
t.Fatalf("Ask: %v", err)
}
if attempts != 2 {
t.Fatalf("attempts = %d, want retry after missing tool", attempts)
}
if !sawTool {
t.Fatal("retry did not execute conformance_echo")
}
if !strings.Contains(resp.Reply, "agent-conformance-ok") {
t.Fatalf("Reply = %q, want tool result marker", resp.Reply)
}
}
func TestAgentProviderConformanceRetriesMissingMarker(t *testing.T) {
var attempts int
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
attempts++
if err := validateConformanceRequest(req, opts); err != nil {
return nil, err
}
if attempts == 1 {
return &ai.Response{Reply: "called conformance_echo and handled guarded delegate refusal without the required marker"}, nil
}
return &ai.Response{Reply: "agent-conformance-ok after guarded delegate refusal"}, nil
}
defer func() { fakeGen = nil }()
sawTool := true
sawBlockedDelegate := true
a := New(
Name("conformance-retry-marker"),
Provider("fake"),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WithMemory(NewInMemory(4)),
WithTool("conformance_echo", "Echo a conformance value.", map[string]any{
"value": map[string]any{"type": "string"},
}, func(ctx context.Context, input map[string]any) (string, error) {
return `{"marker":"agent-conformance-ok"}`, nil
}),
)
resp, err := askWithConformanceRetry(context.Background(), a, "Run the provider conformance check.", &sawTool, &sawBlockedDelegate)
if err != nil {
t.Fatalf("Ask: %v", err)
}
if attempts != 2 {
t.Fatalf("attempts = %d, want retry after missing marker", attempts)
}
if !strings.Contains(resp.Reply, "agent-conformance-ok") {
t.Fatalf("Reply = %q, want conformance marker", resp.Reply)
}
}
func TestAgentProviderConformanceRetriesMissingDelegate(t *testing.T) {
var attempts int
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
attempts++
if err := validateConformanceRequest(req, opts); err != nil {
return nil, err
}
echo := opts.ToolHandler(ctx, ai.ToolCall{
ID: "fake-call-1",
Name: "conformance_echo",
Input: map[string]any{"value": "agent-conformance"},
})
if attempts == 1 {
return &ai.Response{
Reply: "called conformance_echo but skipped delegate",
Answer: echo.Content,
ToolCalls: []ai.ToolCall{
{ID: "fake-call-1", Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: echo.Content},
},
}, nil
}
delegate := opts.ToolHandler(ctx, ai.ToolCall{
ID: "fake-delegate-1",
Name: "delegate",
Input: map[string]any{"task": "summarize the conformance marker", "to": "blocked-reviewer"},
})
return &ai.Response{
Reply: "called conformance_echo and handled guarded delegate refusal",
Answer: echo.Content + " " + delegate.Content,
ToolCalls: []ai.ToolCall{
{ID: "fake-call-1", Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: echo.Content},
{ID: "fake-delegate-1", Name: "delegate", Input: map[string]any{"task": "summarize the conformance marker", "to": "blocked-reviewer"}, Error: delegate.Content},
},
}, nil
}
defer func() { fakeGen = nil }()
var sawTool bool
var sawBlockedDelegate bool
a := New(
Name("conformance-retry-delegate"),
Provider("fake"),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WithMemory(NewInMemory(4)),
ApproveTool(func(tool string, input map[string]any) (bool, string) {
if tool == "delegate" {
sawBlockedDelegate = true
return false, "cross-provider conformance blocks delegate side effects"
}
return true, ""
}),
WithTool("conformance_echo", "Echo a conformance value.", map[string]any{
"value": map[string]any{"type": "string"},
}, func(ctx context.Context, input map[string]any) (string, error) {
sawTool = true
return `{"marker":"agent-conformance-ok"}`, nil
}),
)
resp, err := askWithConformanceRetry(context.Background(), a, "Run the provider conformance check.", &sawTool, &sawBlockedDelegate)
if err != nil {
t.Fatalf("Ask: %v", err)
}
if attempts != 2 {
t.Fatalf("attempts = %d, want retry after missing delegate", attempts)
}
if !sawBlockedDelegate {
t.Fatal("retry did not attempt guarded delegate")
}
if !strings.Contains(resp.Reply, "agent-conformance-ok") && !strings.Contains(resp.Reply, "agent-conformance") {
t.Fatalf("Reply = %q, want conformance marker", resp.Reply)
}
}
func TestAgentProviderConformanceFailsWhenDelegateStillMissing(t *testing.T) {
var attempts int
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
attempts++
if err := validateConformanceRequest(req, opts); err != nil {
return nil, err
}
echo := opts.ToolHandler(ctx, ai.ToolCall{
ID: fmt.Sprintf("fake-call-%d", attempts),
Name: "conformance_echo",
Input: map[string]any{"value": "agent-conformance"},
})
return &ai.Response{
Reply: "called conformance_echo with agent-conformance-ok but skipped delegate",
Answer: echo.Content,
ToolCalls: []ai.ToolCall{
{ID: fmt.Sprintf("fake-call-%d", attempts), Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: echo.Content},
},
}, nil
}
defer func() { fakeGen = nil }()
var sawTool bool
var sawBlockedDelegate bool
a := New(
Name("conformance-retry-delegate-exhausted"),
Provider("fake"),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WithMemory(NewInMemory(4)),
ApproveTool(func(tool string, input map[string]any) (bool, string) {
if tool == "delegate" {
sawBlockedDelegate = true
return false, "cross-provider conformance blocks delegate side effects"
}
return true, ""
}),
WithTool("conformance_echo", "Echo a conformance value.", map[string]any{
"value": map[string]any{"type": "string"},
}, func(ctx context.Context, input map[string]any) (string, error) {
sawTool = true
return `{"marker":"agent-conformance-ok"}`, nil
}),
)
_, err := askWithConformanceRetry(context.Background(), a, "Run the provider conformance check.", &sawTool, &sawBlockedDelegate)
if err == nil || !strings.Contains(err.Error(), "guarded delegate") {
t.Fatalf("Ask error = %v, want missing guarded delegate", err)
}
if attempts != 3 {
t.Fatalf("attempts = %d, want retries through max attempts", attempts)
}
}
func TestAgentExecutesProviderTextToolCallFallback(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
return nil, errors.New("missing tool handler")
}
return &ai.Response{
Reply: `{"name":"conformance_echo","input":{"value":"agent-conformance"}}`,
}, nil
}
defer func() { fakeGen = nil }()
var sawTool bool
a := New(
Name("conformance-text-tool"),
Provider("fake"),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WithMemory(NewInMemory(4)),
WithTool("conformance_echo", "Echo a conformance value.", map[string]any{
"value": map[string]any{"type": "string"},
}, func(ctx context.Context, input map[string]any) (string, error) {
sawTool = true
if input["value"] != "agent-conformance" {
return "", fmt.Errorf("unexpected value %v", input["value"])
}
return `{"marker":"agent-conformance-ok"}`, nil
}),
)
resp, err := a.Ask(context.Background(), "Run the text tool call fallback.")
if err != nil {
t.Fatalf("Ask: %v", err)
}
if !sawTool {
t.Fatal("text tool call fallback did not execute the tool")
}
if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Name != "conformance_echo" {
t.Fatalf("ToolCalls = %+v, want conformance_echo", resp.ToolCalls)
}
if !strings.Contains(resp.Reply, "agent-conformance-ok") {
t.Fatalf("Reply = %q, want tool result marker", resp.Reply)
}
if strings.Contains(resp.Reply, `"name":"conformance_echo"`) {
t.Fatalf("Reply = %q, want tool result instead of raw JSON", resp.Reply)
}
}
func TestAgentExecutesTextToolCallFallbackAfterStructuredToolCall(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
return nil, errors.New("missing tool handler")
}
echo := opts.ToolHandler(ctx, ai.ToolCall{
ID: "structured-echo-1",
Name: "conformance_echo",
Input: map[string]any{"value": "agent-conformance"},
})
return &ai.Response{
Reply: echo.Content + "\n<tool_call name=\"delegate\">{\"task\":\"summarize the conformance marker\",\"to\":\"blocked-reviewer\"}</tool_call>",
Answer: echo.Content,
ToolCalls: []ai.ToolCall{
{ID: "structured-echo-1", Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: echo.Content},
},
}, nil
}
defer func() { fakeGen = nil }()
var sawTool bool
var sawBlockedDelegate bool
a := New(
Name("conformance-mixed-text-tool"),
Provider("fake"),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WithMemory(NewInMemory(4)),
ApproveTool(func(tool string, input map[string]any) (bool, string) {
if tool == "delegate" {
sawBlockedDelegate = true
return false, "cross-provider conformance blocks delegate side effects"
}
return true, ""
}),
WithTool("conformance_echo", "Echo a conformance value.", map[string]any{
"value": map[string]any{"type": "string"},
}, func(ctx context.Context, input map[string]any) (string, error) {
sawTool = true
return `{"marker":"agent-conformance-ok"}`, nil
}),
)
resp, err := a.Ask(context.Background(), "Run the mixed structured/text tool fallback.")
if err != nil {
t.Fatalf("Ask: %v", err)
}
if !sawTool {
t.Fatal("structured conformance_echo did not execute")
}
if !sawBlockedDelegate {
t.Fatal("tagged text delegate fallback did not execute")
}
if len(resp.ToolCalls) != 2 {
t.Fatalf("ToolCalls = %+v, want structured echo and text delegate", resp.ToolCalls)
}
if resp.ToolCalls[1].Name != "delegate" || resp.ToolCalls[1].Error != ai.RefusedApproval {
t.Fatalf("delegate ToolCall = %+v, want refused delegate", resp.ToolCalls[1])
}
if !strings.Contains(resp.Reply, "agent-conformance-ok") {
t.Fatalf("Reply = %q, want conformance marker", resp.Reply)
}
}
+69 -1
View File
@@ -2,6 +2,7 @@ package agent
import (
"context"
"io"
"strings"
"testing"
@@ -16,6 +17,7 @@ import (
// it with a deferred cleanup. Tests in this package are not parallel,
// so a package-level hook is safe.
var fakeGen func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error)
var fakeStream func(ctx context.Context, opts ai.Options, req *ai.Request) (ai.Stream, error)
type fakeModel struct{ opts ai.Options }
@@ -33,10 +35,33 @@ func (m *fakeModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.Gener
return &ai.Response{Reply: "ok"}, nil
}
func (m *fakeModel) Stream(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (ai.Stream, error) {
return nil, nil
if fakeStream != nil {
return fakeStream(ctx, m.opts, req)
}
return &sliceStream{chunks: []string{"ok"}}, nil
}
func (m *fakeModel) String() string { return "fake" }
type sliceStream struct {
chunks []string
idx int
closed bool
}
func (s *sliceStream) Recv() (*ai.Response, error) {
if s.idx >= len(s.chunks) {
return nil, io.EOF
}
chunk := s.chunks[s.idx]
s.idx++
return &ai.Response{Reply: chunk}, nil
}
func (s *sliceStream) Close() error {
s.closed = true
return nil
}
func init() {
ai.Register("fake", func(opts ...ai.Option) ai.Model {
m := &fakeModel{}
@@ -179,3 +204,46 @@ func TestDelegateRequiresTask(t *testing.T) {
t.Errorf("delegate with no task should error; got %q", content)
}
}
func TestCompactingMemorySummarizesAndRecallsArchivedContext(t *testing.T) {
var sawSummary, sawRecall bool
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
for _, msg := range req.Messages {
text := msg.Content.(string)
if strings.Contains(text, "Conversation memory summary") && strings.Contains(text, "alpha project") {
sawSummary = true
}
if strings.Contains(text, "alpha project budget is 42") {
sawRecall = true
}
}
return &ai.Response{Reply: "ok"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("memory"), CompactMemory(4, 2), MemoryRecallLimit(3))
turns := []string{
"alpha project budget is 42",
"beta project owner is sam",
"gamma project deadline is monday",
"delta project status is green",
"epsilon project risk is low",
}
for _, turn := range turns {
if _, err := a.Ask(context.Background(), turn); err != nil {
t.Fatalf("Ask(%q): %v", turn, err)
}
}
if got := len(a.mem.Messages()); got > 4 {
t.Fatalf("compacted memory retained %d messages, want <= 4", got)
}
if _, err := a.Ask(context.Background(), "what was the alpha budget?"); err != nil {
t.Fatalf("Ask recall: %v", err)
}
if !sawSummary {
t.Error("model request did not include a deterministic compacted summary")
}
if !sawRecall {
t.Error("model request did not recall archived matching context")
}
}
+231 -9
View File
@@ -2,6 +2,9 @@ package agent
import (
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"go-micro.dev/v6/ai"
@@ -21,6 +24,28 @@ type Memory interface {
Clear()
}
// MemorySummaryFunc turns older conversation messages into a compact
// replacement message for active context. It is called while the default
// memory is locked, so implementations should be deterministic and avoid
// calling back into the same memory instance.
type MemorySummaryFunc func([]ai.Message) ai.Message
// MemoryCompaction configures deterministic, store-backed context compaction
// for the default memory implementation. When the retained conversation grows
// past MaxMessages, older turns are collapsed into a summary message while the
// newest KeepRecent turns stay verbatim for provider-neutral continuity.
type MemoryCompaction struct {
MaxMessages int
KeepRecent int
Summarize MemorySummaryFunc
}
// MemoryRecall is implemented by memory backends that can retrieve durable
// prior context relevant to a new turn without replaying every stored message.
type MemoryRecall interface {
Recall(query string, limit int) []ai.Message
}
// NewMemory returns the default store-backed memory: an in-process
// conversation buffer (truncated to limit) that persists to the store
// under key, so an agent picks up where it left off after a restart.
@@ -31,6 +56,52 @@ func NewMemory(s store.Store, key string, limit int) Memory {
return m
}
// NewRetrievalMemory returns store-backed memory that keeps a bounded active
// conversation and archives every turn for retrieval. It is useful when callers
// want relevant durable recall without summary compaction in the active context.
// A nil store or empty key keeps only the active in-process buffer.
func NewRetrievalMemory(s store.Store, key string, activeLimit int) Memory {
m := &storeMemory{store: s, key: key, hist: ai.NewHistory(activeLimit), retrieveAll: true}
m.load()
return m
}
// NewCompactingMemory returns store-backed memory with explicit compaction and
// retrieval controls. It keeps all messages in the backing store, compacts older
// turns into a deterministic summary when the conversation exceeds maxMessages,
// and lets callers recall relevant prior turns with Recall.
func NewCompactingMemory(s store.Store, key string, maxMessages, keepRecent int) Memory {
return NewCompactingMemoryWithOptions(s, key, MemoryCompaction{MaxMessages: maxMessages, KeepRecent: keepRecent})
}
// NewCompactingMemoryWithOptions returns store-backed memory configured with
// explicit compaction options, including an optional summarization hook.
func NewCompactingMemoryWithOptions(s store.Store, key string, compaction MemoryCompaction) Memory {
maxMessages := compaction.MaxMessages
keepRecent := compaction.KeepRecent
if keepRecent <= 0 {
keepRecent = maxMessages / 2
}
if keepRecent < 1 {
keepRecent = 1
}
m := &storeMemory{
store: s,
key: key,
// Use an unlimited buffer here; compaction, not truncation, decides
// what remains in active context so a summary can preserve older turns.
hist: ai.NewHistory(0),
compaction: MemoryCompaction{
MaxMessages: maxMessages,
KeepRecent: keepRecent,
Summarize: compaction.Summarize,
},
}
m.load()
m.compact()
return m
}
// NewInMemory returns conversation memory that is not persisted.
func NewInMemory(limit int) Memory {
return &storeMemory{hist: ai.NewHistory(limit)}
@@ -39,16 +110,23 @@ func NewInMemory(limit int) Memory {
// 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
mu sync.Mutex
store store.Store
key string
hist *ai.History
compaction MemoryCompaction
archive []ai.Message
retrieveAll bool
}
func (m *storeMemory) Add(role, content string) {
m.mu.Lock()
if m.retrieveAll {
m.archive = append(m.archive, ai.Message{Role: role, Content: content})
}
m.hist.Add(role, content)
m.mu.Unlock()
m.compact()
m.save()
}
@@ -61,10 +139,51 @@ func (m *storeMemory) Messages() []ai.Message {
func (m *storeMemory) Clear() {
m.mu.Lock()
m.hist.Reset()
m.archive = nil
m.mu.Unlock()
m.save()
}
// Recall returns archived messages whose content contains words from query.
// It is deterministic and provider-neutral: no embeddings or model calls are
// required, but semantic/vector stores can replace Memory for richer retrieval.
// When created with NewRetrievalMemory the archive contains every persisted
// turn; when created with NewCompactingMemory it contains compacted older turns.
func (m *storeMemory) Recall(query string, limit int) []ai.Message {
m.mu.Lock()
defer m.mu.Unlock()
if limit <= 0 {
limit = 5
}
terms := recallTerms(query)
type match struct {
msg ai.Message
score int
index int
}
matches := make([]match, 0, len(m.archive))
for i := len(m.archive) - 1; i >= 0; i-- {
msg := m.archive[i]
if score := recallScore(msg, terms); score > 0 {
matches = append(matches, match{msg: msg, score: score, index: i})
}
}
sort.SliceStable(matches, func(i, j int) bool {
if matches[i].score != matches[j].score {
return matches[i].score > matches[j].score
}
return matches[i].index > matches[j].index
})
if len(matches) > limit {
matches = matches[:limit]
}
out := make([]ai.Message, 0, len(matches))
for _, match := range matches {
out = append(out, match.msg)
}
return out
}
func (m *storeMemory) load() {
if m.store == nil || m.key == "" {
return
@@ -73,12 +192,20 @@ func (m *storeMemory) load() {
if err != nil || len(recs) == 0 {
return
}
var msgs []ai.Message
if err := json.Unmarshal(recs[0].Value, &msgs); err != nil {
return
var state memoryState
if err := json.Unmarshal(recs[0].Value, &state); err != nil {
var msgs []ai.Message
if err := json.Unmarshal(recs[0].Value, &msgs); err != nil {
return
}
state.Messages = msgs
}
m.mu.Lock()
for _, msg := range msgs {
m.archive = state.Archive
if m.retrieveAll && len(m.archive) == 0 {
m.archive = append(m.archive, state.Messages...)
}
for _, msg := range state.Messages {
m.hist.Add(msg.Role, msg.Content)
}
m.mu.Unlock()
@@ -89,10 +216,105 @@ func (m *storeMemory) save() {
return
}
m.mu.Lock()
data, err := json.Marshal(m.hist.Messages())
data, err := json.Marshal(memoryState{
Messages: m.hist.Messages(),
Archive: m.archive,
})
m.mu.Unlock()
if err != nil {
return
}
_ = m.store.Write(&store.Record{Key: m.key, Value: data})
}
func (m *storeMemory) compact() {
if m.compaction.MaxMessages <= 0 {
return
}
m.mu.Lock()
defer m.mu.Unlock()
msgs := m.hist.Messages()
if len(msgs) <= m.compaction.MaxMessages {
return
}
keep := m.compaction.KeepRecent
if keep <= 0 || keep >= m.compaction.MaxMessages {
keep = m.compaction.MaxMessages - 1
}
if keep < 1 {
keep = 1
}
cut := len(msgs) - keep
older := msgs[:cut]
recent := msgs[cut:]
m.archive = append(m.archive, older...)
summarize := m.compaction.Summarize
if summarize == nil {
summarize = defaultMemorySummary
}
summary := summarize(older)
if summary.Role == "" {
summary.Role = "system"
}
m.hist.Reset()
m.hist.Add(summary.Role, summary.Content)
for _, msg := range recent {
m.hist.Add(msg.Role, msg.Content)
}
}
func defaultMemorySummary(msgs []ai.Message) ai.Message {
return ai.Message{
Role: "system",
Content: fmt.Sprintf("Conversation memory summary: %s", summarizeMessages(msgs)),
}
}
func summarizeMessages(msgs []ai.Message) string {
var b strings.Builder
for i, msg := range msgs {
if i > 0 {
b.WriteString(" | ")
}
fmt.Fprintf(&b, "%s: %s", msg.Role, compactText(fmt.Sprint(msg.Content), 120))
}
return b.String()
}
func compactText(s string, max int) string {
s = strings.Join(strings.Fields(s), " ")
if max > 0 && len(s) > max {
return s[:max] + "…"
}
return s
}
func recallScore(msg ai.Message, terms []string) int {
text := strings.ToLower(fmt.Sprint(msg.Content))
score := 0
for _, term := range terms {
if strings.Contains(text, term) {
score++
}
}
return score
}
func recallTerms(query string) []string {
seen := map[string]bool{}
var terms []string
for _, term := range strings.Fields(strings.ToLower(query)) {
term = strings.Trim(term, ".,!?;:\"'()[]{}")
if len(term) < 3 || seen[term] {
continue
}
seen[term] = true
terms = append(terms, term)
}
return terms
}
type memoryState struct {
Messages []ai.Message `json:"messages"`
Archive []ai.Message `json:"archive,omitempty"`
}
+115
View File
@@ -3,9 +3,11 @@ package agent
import (
"context"
"errors"
"strconv"
"strings"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
@@ -62,6 +64,119 @@ func TestWithMemoryUsed(t *testing.T) {
}
}
func TestRetrievalMemoryArchivesAllTurnsAndRanksRelevant(t *testing.T) {
st := store.NewMemoryStore()
m := NewRetrievalMemory(st, "agent/retrieval/history", 2)
m.Add("user", "alpha budget is 42")
m.Add("assistant", "noted")
m.Add("user", "beta owner is lee")
m.Add("assistant", "tracked")
m.Add("user", "alpha owner is sam")
if got := len(m.Messages()); got != 2 {
t.Fatalf("active messages = %d, want bounded history of 2", got)
}
recall, ok := m.(MemoryRecall)
if !ok {
t.Fatal("retrieval memory should support recall")
}
recalled := recall.Recall("alpha budget", 2)
if len(recalled) == 0 {
t.Fatal("expected relevant recalled turns")
}
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
t.Fatalf("top recall = %q, want archived alpha budget turn", got)
}
}
func TestRetrievalMemoryPersistsArchiveAcrossReload(t *testing.T) {
st := store.NewMemoryStore()
m := NewRetrievalMemory(st, "agent/retrieval/reload", 1)
m.Add("user", "alpha budget is 42")
m.Add("assistant", "noted")
m.Add("user", "beta budget is 7")
reloaded := NewRetrievalMemory(st, "agent/retrieval/reload", 1)
recalled := reloaded.(MemoryRecall).Recall("alpha budget", 1)
if len(recalled) != 1 {
t.Fatalf("recalled %d messages, want 1", len(recalled))
}
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
t.Fatalf("reloaded recall = %q, want alpha budget", got)
}
}
func TestCompactingMemoryRecallRanksSpecificMatches(t *testing.T) {
m := NewCompactingMemory(store.NewMemoryStore(), "agent/rank/history", 3, 1).(MemoryRecall)
writer := m.(Memory)
writer.Add("user", "alpha budget is 42")
writer.Add("assistant", "noted")
writer.Add("user", "beta budget is 7")
writer.Add("assistant", "noted")
writer.Add("user", "alpha owner is sam")
recalled := m.Recall("alpha budget", 2)
if len(recalled) == 0 {
t.Fatal("expected recalled messages")
}
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
t.Fatalf("top recall = %q, want alpha budget match", got)
}
}
func TestCompactingMemoryArchivePersistsAndReloads(t *testing.T) {
st := store.NewMemoryStore()
m := NewCompactingMemory(st, "agent/reload/history", 3, 1)
m.Add("user", "alpha budget is 42")
m.Add("assistant", "noted")
m.Add("user", "beta budget is 7")
m.Add("assistant", "noted")
reloaded := NewCompactingMemory(st, "agent/reload/history", 3, 1)
recall, ok := reloaded.(MemoryRecall)
if !ok {
t.Fatal("compacting memory should support recall")
}
recalled := recall.Recall("alpha budget", 1)
if len(recalled) != 1 {
t.Fatalf("recalled %d messages, want 1", len(recalled))
}
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
t.Fatalf("reloaded recall = %q, want alpha budget", got)
}
}
func TestCompactingMemoryUsesCustomSummarizerAndReloadsRecall(t *testing.T) {
st := store.NewMemoryStore()
m := NewCompactingMemoryWithOptions(st, "agent/custom/history", MemoryCompaction{
MaxMessages: 3,
KeepRecent: 1,
Summarize: func(msgs []ai.Message) ai.Message {
return ai.Message{Role: "system", Content: "custom summary count=" + strconv.Itoa(len(msgs))}
},
})
m.Add("user", "alpha budget is 42")
m.Add("assistant", "noted")
m.Add("user", "beta budget is 7")
m.Add("assistant", "noted")
msgs := m.Messages()
if len(msgs) == 0 || msgs[0].Content != "custom summary count=3" {
t.Fatalf("summary = %#v, want custom summarizer output", msgs)
}
reloaded := NewCompactingMemoryWithOptions(st, "agent/custom/history", MemoryCompaction{MaxMessages: 3, KeepRecent: 1})
recall := reloaded.(MemoryRecall)
recalled := recall.Recall("alpha budget", 1)
if len(recalled) != 1 {
t.Fatalf("recalled %d messages, want 1", len(recalled))
}
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
t.Fatalf("reloaded recall = %q, want alpha budget", got)
}
}
// A custom tool is offered to the model and dispatched to its handler.
func TestWithToolExposedAndDispatched(t *testing.T) {
var got map[string]any
+134 -2
View File
@@ -5,7 +5,9 @@ import (
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/broker"
"go-micro.dev/v6/client"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
"go.opentelemetry.io/otel/trace"
@@ -39,8 +41,11 @@ type Options struct {
Provider string
Model string
APIKey string
BaseURL string
Address string
Registry registry.Registry
Client client.Client
Broker broker.Broker
Store store.Store
HistoryLimit int
@@ -54,10 +59,33 @@ type Options struct {
// ModelRetryBackoff is the base delay between transient provider failures
// (grows exponentially per attempt when retries are enabled).
ModelRetryBackoff time.Duration
// ToolTimeout bounds each tool execution (0 disables). The timeout is
// applied before custom tools, delegate, and service RPC calls so context
// deadlines propagate consistently through the agent loop.
ToolTimeout time.Duration
// ToolMaxAttempts bounds tool execution attempts including the first call.
// Default 1; retries are opt-in because tools can have side effects.
ToolMaxAttempts int
// ToolRetryBackoff is the base delay between transient tool failures.
ToolRetryBackoff time.Duration
// Memory is the agent's conversation memory. Nil = the default
// store-backed memory (durable across restarts).
Memory Memory
// MemoryRetrievalLimit enables retrieval-backed default memory without
// compaction. The active conversation stays bounded to this many messages
// while every turn is archived for deterministic recall.
MemoryRetrievalLimit int
// MemoryCompaction enables deterministic compaction/retrieval on the
// default store-backed memory. Custom Memory implementations can expose
// retrieval by implementing MemoryRecall.
MemoryCompaction MemoryCompaction
// MemoryRecallLimit bounds recalled archived turns injected into a model
// request (0 disables recall injection).
MemoryRecallLimit int
// Checkpoint persists agent Ask runs so callers can resume by run id
// after a restart without replaying a run that already completed.
Checkpoint flow.Checkpoint
// MaxSteps bounds the number of tool executions per Ask (0 =
// unbounded). Once exceeded, further tool calls are refused and the
@@ -79,6 +107,11 @@ type Options struct {
// and tool calls. Nil disables instrumentation.
TraceProvider trace.TracerProvider
// TraceInputs controls whether agent observability records include raw
// user messages. It is false by default so spans and persisted run
// timelines carry correlation and shape without leaking prompts.
TraceInputs bool
// tools are developer-registered custom tools (see WithTool).
tools []customTool
// wrappers are developer-registered tool-execution wrappers
@@ -95,6 +128,9 @@ func newOptions(opts ...Option) Options {
ModelTimeout: 30 * time.Second,
ModelMaxAttempts: 1, // retries opt-in via ModelRetry (see field doc)
ModelRetryBackoff: 100 * time.Millisecond,
ToolTimeout: 30 * time.Second,
ToolMaxAttempts: 1,
ToolRetryBackoff: 100 * time.Millisecond,
// On by default and lenient: identical repeated calls are a
// no-progress loop, never useful. Set LoopLimit(0) to disable.
LoopLimit: 3,
@@ -135,6 +171,19 @@ func APIKey(k string) Option {
return func(o *Options) { o.APIKey = k }
}
// BaseURL sets the base URL for the LLM provider. Use this to point
// the provider at a non-default endpoint (e.g., local Ollama, a proxy).
func BaseURL(url string) Option {
return func(o *Options) { o.BaseURL = url }
}
// Address sets the network address for the agent's service endpoint.
// Use "127.0.0.1:0" in local harnesses/tests to bind an ephemeral loopback
// port and avoid advertising the default service address.
func Address(addr string) Option {
return func(o *Options) { o.Address = addr }
}
// WithRegistry sets the service registry.
func WithRegistry(r registry.Registry) Option {
return func(o *Options) { o.Registry = r }
@@ -145,6 +194,13 @@ func WithClient(c client.Client) Option {
return func(o *Options) { o.Client = c }
}
// WithBroker sets the broker used by the agent service endpoint. Use an
// in-memory broker in local harnesses/tests to avoid sharing the package-wide
// default broker listener across concurrently running examples.
func WithBroker(b broker.Broker) Option {
return func(o *Options) { o.Broker = b }
}
// WithStore sets the store for agent memory.
func WithStore(s store.Store) Option {
return func(o *Options) { o.Store = s }
@@ -180,6 +236,14 @@ func ModelCallTimeout(d time.Duration) Option {
return func(o *Options) { o.ModelTimeout = d }
}
// ToolCallTimeout sets the timeout for each tool execution. It bounds custom
// tools, built-in delegate calls, and service RPC tools with the same context
// deadline so mid-run cancellation and slow tools produce safe error results
// instead of unbounded agent runs. Set 0 to disable.
func ToolCallTimeout(d time.Duration) Option {
return func(o *Options) { o.ToolTimeout = d }
}
// ModelRetry sets the provider retry budget and backoff for transient failures.
func ModelRetry(maxAttempts int, backoff time.Duration) Option {
return func(o *Options) {
@@ -188,6 +252,16 @@ func ModelRetry(maxAttempts int, backoff time.Duration) Option {
}
}
// ToolRetry sets the tool retry budget and backoff for transient failures.
// Attempts include the first call. Retries are opt-in because tools may have
// side effects; keep handlers idempotent before enabling this.
func ToolRetry(maxAttempts int, backoff time.Duration) Option {
return func(o *Options) {
o.ToolMaxAttempts = maxAttempts
o.ToolRetryBackoff = backoff
}
}
// WithA2A makes Run serve the agent over the A2A protocol on addr (e.g.
// ":4000"), so other agents can reach it directly by URL without a
// separate gateway. The agent stays a normal go-micro service as well;
@@ -203,6 +277,55 @@ func WithMemory(m Memory) Option {
return func(o *Options) { o.Memory = m }
}
// RetrievalMemory enables deterministic, store-backed retrieval memory for
// the default agent memory without compaction. Active context is capped at
// activeLimit messages while every turn is archived in the store for Recall.
func RetrievalMemory(activeLimit int) Option {
return func(o *Options) {
o.MemoryRetrievalLimit = activeLimit
if o.MemoryRecallLimit == 0 {
o.MemoryRecallLimit = 5
}
}
}
// CompactMemory enables deterministic, store-backed memory compaction for the
// default agent memory. Older turns are summarized once active context exceeds
// maxMessages, keepRecent newest turns remain verbatim, and recalled archived
// turns are injected into matching future asks.
func CompactMemory(maxMessages, keepRecent int) Option {
return func(o *Options) {
o.MemoryCompaction.MaxMessages = maxMessages
o.MemoryCompaction.KeepRecent = keepRecent
if o.MemoryRecallLimit == 0 {
o.MemoryRecallLimit = 5
}
}
}
// MemorySummarizer sets the deterministic summarization hook used by the
// default compacting memory. It is optional; without it, compacted memory uses
// a provider-neutral text summary. The hook receives the older messages being
// removed from active context and returns the replacement summary message.
func MemorySummarizer(fn MemorySummaryFunc) Option {
return func(o *Options) { o.MemoryCompaction.Summarize = fn }
}
// MemoryRecallLimit sets how many archived turns a memory backend may inject
// into a model request for the current Ask. Use 0 to disable retrieval.
func MemoryRecallLimit(n int) Option {
return func(o *Options) { o.MemoryRecallLimit = n }
}
// WithCheckpoint sets the durability backend for agent Ask runs. The
// Checkpoint interface is shared with flow so services, agents, and workflows
// can use one execution history backend. When set, each Ask is saved as a
// single-step run keyed by run id; Resume returns a completed run's persisted
// response instead of calling the model again.
func WithCheckpoint(c flow.Checkpoint) Option {
return func(o *Options) { o.Checkpoint = c }
}
// WrapTool registers a tool-execution wrapper, the tool-side analog of
// a client/server middleware wrapper. Each wrapper takes the next handler
// and returns a new one; code before the next(...) call runs before the
@@ -242,8 +365,17 @@ func WithTool(name, description string, properties map[string]any, handler ToolF
}
}
// TraceProvider enables OpenTelemetry tracing for agent runs. When nil,
// agent tracing and run timeline recording are disabled.
// TraceProvider enables OpenTelemetry tracing for agent runs. The persisted
// run timeline is recorded even when TraceProvider is nil; trace/span IDs are
// added only when a provider is configured.
func TraceProvider(tp trace.TracerProvider) Option {
return func(o *Options) { o.TraceProvider = tp }
}
// TraceInputs opts in to recording raw user messages on agent run events.
// By default inputs are redacted from OpenTelemetry spans and persisted run
// timelines; use this only when the observability backend is approved to store
// prompt content.
func TraceInputs(enabled bool) Option {
return func(o *Options) { o.TraceInputs = enabled }
}
+442 -71
View File
@@ -3,7 +3,9 @@ package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"sort"
"strings"
"time"
@@ -18,56 +20,94 @@ import (
const agentInstrumentationName = "go-micro.dev/v6/agent"
const (
spanNameRun = "agent.run"
spanNameModelCall = "agent.model.call"
spanNameToolCall = "agent.tool.call"
spanNameRun = "agent.run"
spanNameModelCall = "agent.model.call"
spanNameModelStream = "agent.model.stream"
spanNameToolCall = "agent.tool.call"
AttrRunID = "agent.run.id"
AttrParentRunID = "agent.run.parent_id"
AttrAgentName = "agent.name"
AttrProvider = "agent.model.provider"
AttrModel = "agent.model.name"
AttrLatencyMS = "agent.latency_ms"
AttrInputTokens = "agent.tokens.input"
AttrOutputTokens = "agent.tokens.output"
AttrTotalTokens = "agent.tokens.total"
AttrToolName = "agent.tool.name"
AttrDelegate = "agent.delegate"
AttrGuardrailBlock = "agent.guardrail.block"
AttrRefusal = "agent.refusal"
AttrRunID = "agent.run.id"
AttrParentRunID = "agent.run.parent_id"
AttrAgentName = "agent.name"
AttrProvider = "agent.model.provider"
AttrModel = "agent.model.name"
AttrLatencyMS = "agent.latency_ms"
AttrInputTokens = "agent.tokens.input"
AttrOutputTokens = "agent.tokens.output"
AttrTotalTokens = "agent.tokens.total"
AttrAttempt = "agent.model.attempt"
AttrMaxAttempts = "agent.model.max_attempts"
AttrToolName = "agent.tool.name"
AttrDelegate = "agent.delegate"
AttrGuardrailBlock = "agent.guardrail.block"
AttrRefusal = "agent.refusal"
AttrInputChars = "agent.input.chars"
AttrErrorKind = "agent.error.kind"
AttrCheckpointStatus = "agent.checkpoint.status"
AttrCheckpointStage = "agent.checkpoint.stage"
AttrFlowName = "agent.flow.name"
AttrFlowStep = "agent.flow.step"
AttrDispatch = "agent.dispatch"
AttrTrigger = "agent.trigger"
AttrRunEventKind = "agent.event.kind"
)
type RunEvent struct {
Time time.Time `json:"time"`
RunID string `json:"run_id"`
ParentID string `json:"parent_id,omitempty"`
TraceID string `json:"trace_id,omitempty"`
SpanID string `json:"span_id,omitempty"`
Agent string `json:"agent"`
Kind string `json:"kind"`
Name string `json:"name,omitempty"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
LatencyMS int64 `json:"latency_ms,omitempty"`
Tokens Usage `json:"tokens,omitempty"`
Refused string `json:"refused,omitempty"`
Error string `json:"error,omitempty"`
Time time.Time `json:"time"`
RunID string `json:"run_id"`
ParentID string `json:"parent_id,omitempty"`
TraceID string `json:"trace_id,omitempty"`
SpanID string `json:"span_id,omitempty"`
Agent string `json:"agent"`
Kind string `json:"kind"`
Name string `json:"name,omitempty"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
Attempt int `json:"attempt,omitempty"`
MaxAttempts int `json:"max_attempts,omitempty"`
LatencyMS int64 `json:"latency_ms,omitempty"`
Tokens Usage `json:"tokens,omitempty"`
Refused string `json:"refused,omitempty"`
Status string `json:"status,omitempty"`
Error string `json:"error,omitempty"`
ErrorKind string `json:"error_kind,omitempty"`
InputChars int `json:"input_chars,omitempty"`
}
type Usage = ai.Usage
// RunListOptions controls how recorded agent run summaries are returned.
// Zero values preserve the full deterministic run list.
type RunListOptions struct {
// Status, when set, keeps only runs with the matching status
// (for example "running", "done", "canceled", "timeout",
// "rate_limited", "error", or "refused").
Status string
// TraceID, when set, keeps only runs correlated with this trace id.
// A prefix is accepted so operators can paste the shortened trace id
// printed by `micro runs`.
TraceID string
// Limit, when positive, returns the most recently updated runs up to
// the limit. Limited results are ordered newest first.
Limit int
}
// RunSummary is a compact index entry for a recorded agent run.
type RunSummary struct {
RunID string `json:"run_id"`
Agent string `json:"agent"`
ParentID string `json:"parent_id,omitempty"`
TraceID string `json:"trace_id,omitempty"`
SpanID string `json:"span_id,omitempty"`
StartedAt time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
Events int `json:"events"`
LastKind string `json:"last_kind,omitempty"`
LastError string `json:"last_error,omitempty"`
RunID string `json:"run_id"`
Agent string `json:"agent"`
ParentID string `json:"parent_id,omitempty"`
TraceID string `json:"trace_id,omitempty"`
SpanID string `json:"span_id,omitempty"`
StartedAt time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
DurationMS int64 `json:"duration_ms,omitempty"`
Events int `json:"events"`
Status string `json:"status,omitempty"`
Checkpoint string `json:"checkpoint,omitempty"`
Stage string `json:"stage,omitempty"`
LastKind string `json:"last_kind,omitempty"`
LastError string `json:"last_error,omitempty"`
LastErrorKind string `json:"last_error_kind,omitempty"`
}
func (a *agentImpl) tracer() trace.Tracer {
@@ -75,21 +115,40 @@ func (a *agentImpl) tracer() trace.Tracer {
}
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})
runEvent := RunEvent{Time: start, RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "run", InputChars: len(message)}
if a.opts.TraceInputs {
runEvent.Name = message
}
if a.opts.TraceProvider == nil {
a.recordRunEvent(runEvent)
return ctx, func(err error) {
latency := time.Since(start).Milliseconds()
if err != nil {
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "error", LatencyMS: latency, Error: err.Error(), ErrorKind: string(ai.ClassifyError(err))})
return
}
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "done", LatencyMS: latency})
}
}
attrs := appendRunInfoAttributes([]attribute.KeyValue{
attribute.String(AttrRunID, info.RunID),
attribute.String(AttrParentRunID, info.ParentID),
attribute.String(AttrAgentName, info.Agent),
}, info)
ctx, span := a.tracer().Start(ctx, spanNameRun, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(attrs...))
a.recordSpanEvent(span, runEvent)
return ctx, func(err error) {
latency := time.Since(start).Milliseconds()
span.SetAttributes(attribute.Int64(AttrLatencyMS, latency))
if err != nil {
span.SetAttributes(attribute.String(AttrErrorKind, string(ai.ClassifyError(err))))
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "error", LatencyMS: latency, Error: err.Error()})
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "error", LatencyMS: latency, Error: err.Error(), ErrorKind: string(ai.ClassifyError(err))})
} else {
span.SetStatus(codes.Ok, "")
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "done", LatencyMS: latency})
@@ -105,17 +164,44 @@ type tracedModel struct {
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()
if m.a.opts.TraceProvider == nil {
resp, err := m.Model.Generate(ctx, req, opts...)
dur := time.Since(start).Milliseconds()
usage := ai.Usage{}
if resp != nil {
usage = resp.Usage
}
e := RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "model", Provider: provider, Model: model, Attempt: info.Attempt, MaxAttempts: info.MaxAttempts, LatencyMS: dur, Tokens: usage}
if err != nil {
e.Error = err.Error()
e.ErrorKind = string(ai.ClassifyError(err))
}
m.a.recordRunEvent(e)
return resp, err
}
attrs := appendRunInfoAttributes([]attribute.KeyValue{
attribute.String(AttrRunID, info.RunID),
attribute.String(AttrParentRunID, info.ParentID),
attribute.String(AttrAgentName, info.Agent),
attribute.String(AttrProvider, provider),
attribute.String(AttrModel, model),
}, info)
ctx, span := m.a.tracer().Start(ctx, spanNameModelCall, trace.WithAttributes(attrs...))
resp, err := m.Model.Generate(ctx, req, opts...)
dur := time.Since(start).Milliseconds()
attrs := []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
attrs = []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
if info.Attempt > 0 {
attrs = append(attrs, attribute.Int(AttrAttempt, info.Attempt))
}
if info.MaxAttempts > 0 {
attrs = append(attrs, attribute.Int(AttrMaxAttempts, info.MaxAttempts))
}
usage := ai.Usage{}
if resp != nil {
usage = resp.Usage
@@ -123,20 +209,139 @@ func (m *tracedModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.
}
span.SetAttributes(attrs...)
if err != nil {
span.SetAttributes(attribute.String(AttrErrorKind, string(ai.ClassifyError(err))))
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
} else {
span.SetStatus(codes.Ok, "")
}
span.End()
e := RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "model", Provider: provider, Model: model, LatencyMS: dur, Tokens: usage}
e := RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "model", Provider: provider, Model: model, Attempt: info.Attempt, MaxAttempts: info.MaxAttempts, LatencyMS: dur, Tokens: usage}
if err != nil {
e.Error = err.Error()
e.ErrorKind = string(ai.ClassifyError(err))
}
m.a.recordSpanEvent(span, e)
span.End()
return resp, err
}
func (m *tracedModel) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
info, _ := ai.RunInfoFrom(ctx)
provider := m.String()
model := m.Options().Model
start := time.Now()
if m.a.opts.TraceProvider == nil {
stream, err := m.Model.Stream(ctx, req, opts...)
if err != nil {
m.a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "stream", Provider: provider, Model: model, Attempt: info.Attempt, MaxAttempts: info.MaxAttempts, LatencyMS: time.Since(start).Milliseconds(), Error: err.Error(), ErrorKind: string(ai.ClassifyError(err))})
return nil, err
}
return &tracedStream{Stream: stream, a: m.a, info: info, provider: provider, model: model, start: start}, nil
}
attrs := appendRunInfoAttributes([]attribute.KeyValue{
attribute.String(AttrRunID, info.RunID),
attribute.String(AttrParentRunID, info.ParentID),
attribute.String(AttrAgentName, info.Agent),
attribute.String(AttrProvider, provider),
attribute.String(AttrModel, model),
}, info)
ctx, span := m.a.tracer().Start(ctx, spanNameModelStream, trace.WithAttributes(attrs...))
stream, err := m.Model.Stream(ctx, req, opts...)
if err != nil {
dur := time.Since(start).Milliseconds()
span.SetAttributes(attribute.Int64(AttrLatencyMS, dur), attribute.String(AttrErrorKind, string(ai.ClassifyError(err))))
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
e := RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "stream", Provider: provider, Model: model, Attempt: info.Attempt, MaxAttempts: info.MaxAttempts, LatencyMS: dur, Error: err.Error(), ErrorKind: string(ai.ClassifyError(err))}
m.a.recordSpanEvent(span, e)
span.End()
return nil, err
}
return &tracedStream{Stream: stream, a: m.a, info: info, provider: provider, model: model, start: start, span: span}, nil
}
type tracedStream struct {
ai.Stream
a *agentImpl
info ai.RunInfo
provider string
model string
start time.Time
span trace.Span
usage ai.Usage
closed bool
}
func (s *tracedStream) Recv() (*ai.Response, error) {
resp, err := s.Stream.Recv()
if resp != nil {
s.usage = mergeUsage(s.usage, resp.Usage)
}
if err != nil {
if errors.Is(err, io.EOF) {
s.finish(nil)
} else {
s.finish(err)
}
}
return resp, err
}
func (s *tracedStream) Close() error {
err := s.Stream.Close()
s.finish(err)
return err
}
func (s *tracedStream) finish(err error) {
if s.closed {
return
}
s.closed = true
dur := time.Since(s.start).Milliseconds()
e := RunEvent{Time: time.Now(), RunID: s.info.RunID, ParentID: s.info.ParentID, Agent: s.info.Agent, Kind: "stream", Provider: s.provider, Model: s.model, Attempt: s.info.Attempt, MaxAttempts: s.info.MaxAttempts, LatencyMS: dur, Tokens: s.usage}
if err != nil {
e.Error = err.Error()
e.ErrorKind = string(ai.ClassifyError(err))
}
if s.span == nil {
s.a.recordRunEvent(e)
return
}
attrs := appendUsage([]attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}, s.usage)
if s.info.Attempt > 0 {
attrs = append(attrs, attribute.Int(AttrAttempt, s.info.Attempt))
}
if s.info.MaxAttempts > 0 {
attrs = append(attrs, attribute.Int(AttrMaxAttempts, s.info.MaxAttempts))
}
if err != nil {
attrs = append(attrs, attribute.String(AttrErrorKind, e.ErrorKind))
s.span.RecordError(err)
s.span.SetStatus(codes.Error, err.Error())
} else {
s.span.SetStatus(codes.Ok, "")
}
s.span.SetAttributes(attrs...)
s.a.recordSpanEvent(s.span, e)
s.span.End()
}
func mergeUsage(current, next ai.Usage) ai.Usage {
if next.InputTokens > current.InputTokens {
current.InputTokens = next.InputTokens
}
if next.OutputTokens > current.OutputTokens {
current.OutputTokens = next.OutputTokens
}
if next.TotalTokens > current.TotalTokens {
current.TotalTokens = next.TotalTokens
}
return current
}
func appendUsage(attrs []attribute.KeyValue, u ai.Usage) []attribute.KeyValue {
if u.InputTokens > 0 {
attrs = append(attrs, attribute.Int(AttrInputTokens, u.InputTokens))
@@ -151,21 +356,36 @@ func appendUsage(attrs []attribute.KeyValue, u ai.Usage) []attribute.KeyValue {
}
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()
if a.opts.TraceProvider == nil {
res := next(ctx, call)
dur := time.Since(start).Milliseconds()
resErr := resultError(res)
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, LatencyMS: dur, Refused: res.Refused, Error: resErr, ErrorKind: classifyToolError(resErr)})
return res
}
ctx, span := a.tracer().Start(ctx, spanNameToolCall, trace.WithAttributes(
attribute.String(AttrRunID, info.RunID),
attribute.String(AttrParentRunID, info.ParentID),
attribute.String(AttrAgentName, info.Agent),
attribute.String(AttrToolName, call.Name),
attribute.Bool(AttrDelegate, call.Name == toolDelegate),
))
res := next(ctx, call)
dur := time.Since(start).Milliseconds()
attrs := []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
if res.Refused != "" {
attrs = append(attrs, attribute.Bool(AttrGuardrailBlock, true), attribute.String(AttrRefusal, res.Refused))
}
span.SetAttributes(attrs...)
resErr := resultError(res)
if kind := classifyToolError(resErr); kind != "" {
attrs = append(attrs, attribute.String(AttrErrorKind, kind))
}
span.SetAttributes(attrs...)
if res.Refused != "" {
span.SetStatus(codes.Error, res.Refused)
} else if resErr != "" {
@@ -173,8 +393,8 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
} else {
span.SetStatus(codes.Ok, "")
}
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, LatencyMS: dur, Refused: res.Refused, Error: resErr, ErrorKind: classifyToolError(resErr)})
span.End()
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, LatencyMS: dur, Refused: res.Refused, Error: resErr})
return res
}
}
@@ -191,16 +411,106 @@ func resultError(res ai.ToolResult) string {
return ""
}
func classifyToolError(err string) string {
switch {
case err == "":
return ""
case strings.Contains(strings.ToLower(err), "context canceled"):
return string(ai.ErrorKindCanceled)
case strings.Contains(strings.ToLower(err), "deadline exceeded"):
return string(ai.ErrorKindTimeout)
default:
return string(ai.ErrorKindProvider)
}
}
func (a *agentImpl) recordTimelineEvent(ctx context.Context, e RunEvent) {
span := trace.SpanFromContext(ctx)
if span.SpanContext().IsValid() {
a.recordSpanEvent(span, e)
return
}
a.recordRunEvent(e)
}
func (a *agentImpl) recordSpanEvent(span trace.Span, e RunEvent) {
if sc := span.SpanContext(); sc.IsValid() {
e.TraceID = sc.TraceID().String()
e.SpanID = sc.SpanID().String()
}
span.AddEvent("agent."+e.Kind, trace.WithTimestamp(e.Time), trace.WithAttributes(runEventAttributes(e)...))
a.recordRunEvent(e)
}
func runEventAttributes(e RunEvent) []attribute.KeyValue {
attrs := []attribute.KeyValue{
attribute.String(AttrRunID, e.RunID),
attribute.String(AttrAgentName, e.Agent),
attribute.String(AttrRunEventKind, e.Kind),
}
if e.ParentID != "" {
attrs = append(attrs, attribute.String(AttrParentRunID, e.ParentID))
}
if e.Name != "" {
attrs = append(attrs, attribute.String("agent.event.name", e.Name))
}
if e.Provider != "" {
attrs = append(attrs, attribute.String(AttrProvider, e.Provider))
}
if e.Model != "" {
attrs = append(attrs, attribute.String(AttrModel, e.Model))
}
if e.Attempt > 0 {
attrs = append(attrs, attribute.Int(AttrAttempt, e.Attempt))
}
if e.MaxAttempts > 0 {
attrs = append(attrs, attribute.Int(AttrMaxAttempts, e.MaxAttempts))
}
if e.LatencyMS > 0 {
attrs = append(attrs, attribute.Int64(AttrLatencyMS, e.LatencyMS))
}
if e.InputChars > 0 {
attrs = append(attrs, attribute.Int(AttrInputChars, e.InputChars))
}
attrs = appendUsage(attrs, e.Tokens)
if e.Refused != "" {
attrs = append(attrs, attribute.Bool(AttrGuardrailBlock, true), attribute.String(AttrRefusal, e.Refused))
}
if e.Error != "" {
attrs = append(attrs, attribute.String("agent.error", e.Error))
}
if e.ErrorKind != "" {
attrs = append(attrs, attribute.String(AttrErrorKind, e.ErrorKind))
}
if e.Kind == "checkpoint" {
if e.Status != "" {
attrs = append(attrs, attribute.String(AttrCheckpointStatus, e.Status))
}
if e.Name != "" {
attrs = append(attrs, attribute.String(AttrCheckpointStage, e.Name))
}
}
return attrs
}
func appendRunInfoAttributes(attrs []attribute.KeyValue, info ai.RunInfo) []attribute.KeyValue {
if info.Flow != "" {
attrs = append(attrs, attribute.String(AttrFlowName, info.Flow))
}
if info.Step != "" {
attrs = append(attrs, attribute.String(AttrFlowStep, info.Step))
}
if info.Dispatch != "" {
attrs = append(attrs, attribute.String(AttrDispatch, info.Dispatch))
}
if info.Trigger != "" {
attrs = append(attrs, attribute.String(AttrTrigger, info.Trigger))
}
return attrs
}
func (a *agentImpl) recordRunEvent(e RunEvent) {
if a.opts.TraceProvider == nil || e.RunID == "" {
if e.RunID == "" {
return
}
b, _ := json.Marshal(e)
@@ -210,6 +520,12 @@ func (a *agentImpl) recordRunEvent(e RunEvent) {
// ListRunSummaries returns a deterministic summary of recorded runs for agentName.
func ListRunSummaries(s store.Store, agentName string) ([]RunSummary, error) {
return ListRunSummariesWithOptions(s, agentName, RunListOptions{})
}
// ListRunSummariesWithOptions returns summaries of recorded runs for agentName,
// optionally filtered by status and limited to the most recently updated runs.
func ListRunSummariesWithOptions(s store.Store, agentName string, opts RunListOptions) ([]RunSummary, error) {
st := store.Scope(s, "agent", agentName)
keys, err := st.List(store.ListPrefix("runs/"))
if err != nil {
@@ -240,16 +556,18 @@ func ListRunSummaries(s store.Store, agentName string) ([]RunSummary, error) {
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,
RunID: id,
Agent: first.Agent,
ParentID: first.ParentID,
TraceID: first.TraceID,
SpanID: first.SpanID,
StartedAt: first.Time,
UpdatedAt: last.Time,
DurationMS: last.Time.Sub(first.Time).Milliseconds(),
Events: len(events),
Status: runStatus(events),
LastKind: last.Kind,
LastError: last.Error,
}
for _, e := range events {
if e.Agent != "" {
@@ -264,15 +582,68 @@ func ListRunSummaries(s store.Store, agentName string) ([]RunSummary, error) {
if e.SpanID != "" {
summary.SpanID = e.SpanID
}
if e.Kind == "checkpoint" {
summary.Checkpoint = e.Status
summary.Stage = e.Name
}
if e.Error != "" {
summary.LastError = e.Error
}
if e.ErrorKind != "" {
summary.LastErrorKind = e.ErrorKind
}
}
if opts.Status != "" && summary.Status != opts.Status {
continue
}
if opts.TraceID != "" && !strings.HasPrefix(summary.TraceID, opts.TraceID) {
continue
}
summaries = append(summaries, summary)
}
if opts.Limit > 0 {
sort.SliceStable(summaries, func(i, j int) bool {
return summaries[i].UpdatedAt.After(summaries[j].UpdatedAt)
})
if len(summaries) > opts.Limit {
summaries = summaries[:opts.Limit]
}
}
return summaries, nil
}
func runStatus(events []RunEvent) string {
if len(events) == 0 {
return ""
}
status := "running"
for _, e := range events {
if e.Refused != "" && status == "running" {
status = "refused"
}
if e.Error != "" || e.Kind == "error" {
status = runErrorStatus(e.ErrorKind)
}
if e.Kind == "done" {
status = "done"
}
}
return status
}
func runErrorStatus(kind string) string {
switch ai.ErrorKind(kind) {
case ai.ErrorKindCanceled:
return "canceled"
case ai.ErrorKindTimeout:
return "timeout"
case ai.ErrorKindRateLimited:
return "rate_limited"
default:
return "error"
}
}
func LoadRunEvents(s store.Store, agentName, runID string) ([]RunEvent, error) {
st := store.Scope(s, "agent", agentName)
keys, err := st.List(store.ListPrefix("runs/" + runID + "/"))
+518 -9
View File
@@ -3,15 +3,24 @@ package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"testing"
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/store"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
)
const codesError = codes.Error
type otelTestModel struct{ opts ai.Options }
func (m *otelTestModel) Init(opts ...ai.Option) error {
@@ -27,7 +36,11 @@ func (m *otelTestModel) Stream(context.Context, *ai.Request, ...ai.GenerateOptio
}
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}})
if strings.Contains(req.Prompt, "delegate") {
_ = m.opts.ToolHandler(ctx, ai.ToolCall{ID: "call-delegate", Name: toolDelegate, Input: map[string]any{"task": "subtask"}})
} else if !strings.Contains(req.Prompt, "subtask") {
_ = m.opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "probe", Input: map[string]any{"ok": true}})
}
}
return &ai.Response{Reply: "done", Usage: ai.Usage{InputTokens: 2, OutputTokens: 3, TotalTokens: 5}}, nil
}
@@ -46,16 +59,54 @@ func TestAgentOpenTelemetrySpans(t *testing.T) {
}
spans := exp.GetSpans().Snapshots()
want := map[string]bool{spanNameRun: false, spanNameModelCall: false, spanNameToolCall: false}
var runID string
for _, s := range spans {
if _, ok := want[s.Name()]; ok {
want[s.Name()] = true
}
attrs := spanAttributes(s.Attributes())
if s.Name() == spanNameRun {
runID = attrs[AttrRunID]
}
}
for name, seen := range want {
if !seen {
t.Fatalf("span %s not emitted; got %d spans", name, len(spans))
}
}
if runID == "" {
t.Fatal("run span missing run id attribute")
}
var runEvents []trace.Event
for _, s := range spans {
if s.Name() == spanNameRun {
runEvents = s.Events()
break
}
}
if !spanEventHasRunInfo(runEvents, "agent.run", runID, "runner") || !spanEventHasRunInfo(runEvents, "agent.done", runID, "runner") {
t.Fatalf("run span missing run-info events: %#v", runEvents)
}
for _, s := range spans {
if s.Name() != spanNameModelCall && s.Name() != spanNameToolCall {
continue
}
attrs := spanAttributes(s.Attributes())
if attrs[AttrRunID] != runID || attrs[AttrAgentName] != "runner" {
t.Fatalf("%s missing run correlation attributes: %#v", s.Name(), attrs)
}
if s.Name() == spanNameModelCall {
if attrs[AttrAttempt] != "1" || attrs[AttrMaxAttempts] != "1" {
t.Fatalf("model span missing attempt attributes: %#v", attrs)
}
if !spanEventHasRunInfo(s.Events(), "agent.model", runID, "runner") {
t.Fatalf("model span missing model event: %#v", s.Events())
}
}
if s.Name() == spanNameToolCall && !spanEventHasRunInfo(s.Events(), "agent.tool", runID, "runner") {
t.Fatalf("tool span missing tool event: %#v", s.Events())
}
}
keys, err := store.Scope(st, "agent", "runner").List(store.ListPrefix("runs/"))
if err != nil {
t.Fatal(err)
@@ -73,6 +124,12 @@ func TestAgentOpenTelemetrySpans(t *testing.T) {
if summaries[0].LastKind != "done" {
t.Fatalf("LastKind = %q, want done", summaries[0].LastKind)
}
if summaries[0].Status != "done" {
t.Fatalf("Status = %q, want done", summaries[0].Status)
}
if summaries[0].DurationMS < 0 {
t.Fatalf("DurationMS = %d, want non-negative", summaries[0].DurationMS)
}
if summaries[0].TraceID == "" || summaries[0].SpanID == "" {
t.Fatalf("summary missing trace correlation: %#v", summaries[0])
}
@@ -85,7 +142,220 @@ func TestAgentOpenTelemetrySpans(t *testing.T) {
}
}
func TestAgentOpenTelemetryNoopWhenUnconfigured(t *testing.T) {
func TestAgentRunObservabilityRedactsInputByDefault(t *testing.T) {
secret := "deploy production with token sk-secret"
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
st := store.NewMemoryStore()
a := New(Name("redactor"), Provider("oteltest"), WithStore(st), TraceProvider(tp))
if _, err := a.Ask(context.Background(), secret); err != nil {
t.Fatal(err)
}
spans := exp.GetSpans().Snapshots()
var sawInputChars bool
for _, s := range spans {
for _, event := range s.Events() {
attrs := spanAttributes(event.Attributes)
if attrs["agent.event.name"] == secret {
t.Fatalf("span event leaked raw input: %#v", event)
}
if attrs[AttrInputChars] == fmt.Sprint(len(secret)) {
sawInputChars = true
}
}
}
if !sawInputChars {
t.Fatal("run event missing redacted input length attribute")
}
summaries, err := ListRunSummaries(st, "redactor")
if err != nil {
t.Fatal(err)
}
events, err := LoadRunEvents(st, "redactor", summaries[0].RunID)
if err != nil {
t.Fatal(err)
}
for _, event := range events {
if event.Name == secret {
t.Fatalf("persisted run event leaked raw input: %#v", event)
}
if event.Kind == "run" && event.InputChars != len(secret) {
t.Fatalf("run event InputChars = %d, want %d", event.InputChars, len(secret))
}
}
}
func TestAgentTraceInputsOptInRecordsInput(t *testing.T) {
message := "operator-approved diagnostic prompt"
st := store.NewMemoryStore()
a := New(Name("input-opt-in"), Provider("oteltest"), WithStore(st), TraceInputs(true))
if _, err := a.Ask(context.Background(), message); err != nil {
t.Fatal(err)
}
summaries, err := ListRunSummaries(st, "input-opt-in")
if err != nil {
t.Fatal(err)
}
events, err := LoadRunEvents(st, "input-opt-in", summaries[0].RunID)
if err != nil {
t.Fatal(err)
}
for _, event := range events {
if event.Kind == "run" && event.Name == message {
return
}
}
t.Fatalf("opt-in run event did not record message: %#v", events)
}
type failingOtelModel struct{ opts ai.Options }
func (m *failingOtelModel) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&m.opts)
}
return nil
}
func (m *failingOtelModel) Options() ai.Options { return m.opts }
func (m *failingOtelModel) String() string { return "otelfail" }
func (m *failingOtelModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
return nil, nil
}
func (m *failingOtelModel) Generate(context.Context, *ai.Request, ...ai.GenerateOption) (*ai.Response, error) {
return nil, errors.New("provider exploded")
}
func init() {
ai.Register("otelfail", func(opts ...ai.Option) ai.Model { return &failingOtelModel{opts: ai.NewOptions(opts...)} })
}
func TestAgentOpenTelemetrySpansModelFailure(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
st := store.NewMemoryStore()
a := New(Name("failing-runner"), Provider("otelfail"), WithStore(st), TraceProvider(tp))
if _, err := a.Ask(context.Background(), "hello"); err == nil {
t.Fatal("Ask succeeded, want provider error")
}
spans := exp.GetSpans().Snapshots()
var sawRunError, sawModelError bool
for _, s := range spans {
attrs := spanAttributes(s.Attributes())
switch s.Name() {
case spanNameRun:
if attrs[AttrAgentName] == "failing-runner" && s.Status().Code == codesError {
sawRunError = true
}
case spanNameModelCall:
if attrs[AttrAgentName] == "failing-runner" && attrs[AttrAttempt] == "1" && attrs[AttrErrorKind] == string(ai.ErrorKindUnknown) && s.Status().Code == codesError {
sawModelError = true
}
}
}
if !sawRunError || !sawModelError {
t.Fatalf("missing error spans: run=%v model=%v spans=%d", sawRunError, sawModelError, len(spans))
}
summaries, err := ListRunSummaries(st, "failing-runner")
if err != nil {
t.Fatal(err)
}
if len(summaries) != 1 || summaries[0].Status != "error" || summaries[0].LastError == "" {
t.Fatalf("unexpected failure summary: %#v", summaries)
}
events, err := LoadRunEvents(st, "failing-runner", summaries[0].RunID)
if err != nil {
t.Fatal(err)
}
var sawModelEvent bool
for _, event := range events {
if event.Kind == "model" && event.Attempt == 1 && event.MaxAttempts == 1 && event.Error != "" && event.ErrorKind == string(ai.ErrorKindUnknown) {
sawModelEvent = true
}
}
if !sawModelEvent {
t.Fatalf("missing failed model event with attempt metadata: %#v", events)
}
}
func spanEventHasRunInfo(events []trace.Event, name, runID, agentName string) bool {
for _, event := range events {
if event.Name != name {
continue
}
attrs := spanAttributes(event.Attributes)
wantKind := strings.TrimPrefix(name, "agent.")
if attrs[AttrRunID] == runID && attrs[AttrAgentName] == agentName && attrs[AttrRunEventKind] == wantKind {
return true
}
}
return false
}
func spanAttributes(attrs []attribute.KeyValue) map[string]string {
out := make(map[string]string, len(attrs))
for _, attr := range attrs {
out[string(attr.Key)] = fmt.Sprint(attr.Value.AsInterface())
}
return out
}
func TestAgentOpenTelemetrySpansDelegateLineage(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
st := store.NewMemoryStore()
a := New(Name("conductor"), Provider("oteltest"), WithStore(st), TraceProvider(tp))
if _, err := a.Ask(context.Background(), "delegate please"); err != nil {
t.Fatal(err)
}
spans := exp.GetSpans().Snapshots()
var parentRunID string
var delegateSpanID string
var subRunSeen bool
for _, s := range spans {
attrs := spanAttributes(s.Attributes())
if s.Name() == spanNameRun && attrs[AttrAgentName] == "conductor" {
parentRunID = attrs[AttrRunID]
}
}
if parentRunID == "" {
t.Fatal("parent run span missing run id")
}
for _, s := range spans {
attrs := spanAttributes(s.Attributes())
if s.Name() == spanNameToolCall && attrs[AttrToolName] == toolDelegate {
if attrs[AttrDelegate] != "true" || attrs[AttrRunID] != parentRunID {
t.Fatalf("delegate span missing correlation attributes: %#v", attrs)
}
delegateSpanID = s.SpanContext().SpanID().String()
}
}
if delegateSpanID == "" {
t.Fatal("delegate tool span not emitted")
}
for _, s := range spans {
attrs := spanAttributes(s.Attributes())
if s.Name() == spanNameRun && attrs[AttrAgentName] == "conductor.sub" {
if attrs[AttrParentRunID] != parentRunID {
t.Fatalf("sub-agent run parent attr = %q, want %q", attrs[AttrParentRunID], parentRunID)
}
if s.Parent().SpanID().String() != delegateSpanID {
t.Fatalf("sub-agent run parent span = %s, want delegate span %s", s.Parent().SpanID(), delegateSpanID)
}
subRunSeen = true
}
}
if !subRunSeen {
t.Fatalf("sub-agent run span not emitted; got %d spans", len(spans))
}
}
func TestAgentRunTimelineRecordsModelAndToolWithoutTraceProvider(t *testing.T) {
st := store.NewMemoryStore()
a := New(Name("runner-noop"), Provider("oteltest"), WithStore(st), WithTool("probe", "probe", nil, func(context.Context, map[string]any) (string, error) { return "ok", nil }))
if _, err := a.Ask(context.Background(), "hello"); err != nil {
@@ -95,11 +365,105 @@ func TestAgentOpenTelemetryNoopWhenUnconfigured(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(keys) != 0 {
t.Fatalf("expected no run timeline without TraceProvider, got %v", keys)
if len(keys) == 0 {
t.Fatal("expected run timeline without TraceProvider")
}
if _, ok := a.(*agentImpl).model.(*tracedModel); ok {
t.Fatal("model should not be wrapped when TraceProvider is nil")
summaries, err := ListRunSummaries(st, "runner-noop")
if err != nil {
t.Fatal(err)
}
if len(summaries) != 1 {
t.Fatalf("got %d summaries, want 1", len(summaries))
}
if summaries[0].Status != "done" || summaries[0].LastKind != "done" {
t.Fatalf("unexpected summary without TraceProvider: %#v", summaries[0])
}
if summaries[0].TraceID != "" || summaries[0].SpanID != "" {
t.Fatalf("unexpected trace correlation without TraceProvider: %#v", summaries[0])
}
events, err := LoadRunEvents(st, "runner-noop", summaries[0].RunID)
if err != nil {
t.Fatal(err)
}
seen := map[string]bool{"run": false, "model": false, "tool": false, "done": false}
for _, e := range events {
seen[e.Kind] = true
if e.TraceID != "" || e.SpanID != "" {
t.Fatalf("event has trace correlation without TraceProvider: %#v", e)
}
}
for kind, ok := range seen {
if !ok {
t.Fatalf("missing %s event in timeline: %#v", kind, events)
}
}
}
func TestAgentCheckpointAndResumeTimelineEvents(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
st := store.NewMemoryStore()
cp := flow.StoreCheckpoint(st, "resume-otel-agent")
first := true
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if first {
first = false
return nil, errors.New("temporary provider failure")
}
return &ai.Response{Reply: "resumed"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("resume-otel-agent"), WithStore(st), WithCheckpoint(cp), TraceProvider(tp))
_, err := a.Ask(context.Background(), "resume me")
if err == nil {
t.Fatal("Ask succeeded, want simulated failure")
}
runs, err := cp.List(context.Background())
if err != nil {
t.Fatal(err)
}
if len(runs) != 1 {
t.Fatalf("checkpointed runs = %d, want 1", len(runs))
}
resp, err := Resume(context.Background(), a, runs[0].ID)
if err != nil {
t.Fatalf("Resume: %v", err)
}
if resp.Reply != "resumed" {
t.Fatalf("reply = %q, want resumed", resp.Reply)
}
events, err := LoadRunEvents(st, "resume-otel-agent", runs[0].ID)
if err != nil {
t.Fatal(err)
}
seen := map[string]bool{"checkpoint": false, "resume": false}
for _, e := range events {
if _, ok := seen[e.Kind]; ok {
seen[e.Kind] = true
}
}
for kind, ok := range seen {
if !ok {
t.Fatalf("missing %s event in timeline: %#v", kind, events)
}
}
var resumeSpanEvent bool
for _, s := range exp.GetSpans().Snapshots() {
if s.Name() != spanNameRun {
continue
}
for _, e := range s.Events() {
if e.Name == "agent.resume" {
resumeSpanEvent = true
}
}
}
if !resumeSpanEvent {
t.Fatal("run span missing agent.resume event")
}
}
@@ -144,7 +508,8 @@ func TestListRunSummaries(t *testing.T) {
{Time: time.Unix(0, 1), RunID: "run-a", Agent: "runner", TraceID: "trace-a", SpanID: "span-a", Kind: "run", Name: "first"},
{Time: time.Unix(0, 2), RunID: "run-a", Agent: "runner", Kind: "tool", Name: "probe"},
{Time: time.Unix(0, 3), RunID: "run-b", Agent: "runner", ParentID: "parent", Kind: "run", Name: "second"},
{Time: time.Unix(0, 4), RunID: "run-b", Agent: "runner", ParentID: "parent", Kind: "error", Error: "boom"},
{Time: time.Unix(0, 4), RunID: "run-b", Agent: "runner", ParentID: "parent", Kind: "checkpoint", Name: "ask", Status: "failed"},
{Time: time.Unix(0, 5), RunID: "run-b", Agent: "runner", ParentID: "parent", Kind: "error", Error: "context deadline exceeded", ErrorKind: string(ai.ErrorKindTimeout)},
}
for _, e := range events {
b, err := json.Marshal(e)
@@ -164,10 +529,154 @@ func TestListRunSummaries(t *testing.T) {
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)) {
if got[0].RunID != "run-a" || got[0].TraceID != "trace-a" || got[0].SpanID != "span-a" || got[0].Events != 2 || got[0].Status != "running" || got[0].DurationMS != 0 || got[0].LastKind != "tool" || !got[0].UpdatedAt.Equal(time.Unix(0, 2)) {
t.Fatalf("unexpected run-a summary: %#v", got[0])
}
if got[1].RunID != "run-b" || got[1].ParentID != "parent" || got[1].Events != 2 || got[1].LastKind != "error" || got[1].LastError != "boom" {
if got[1].RunID != "run-b" || got[1].ParentID != "parent" || got[1].Events != 3 || got[1].Status != "timeout" || got[1].DurationMS != 0 || got[1].LastKind != "error" || got[1].Checkpoint != "failed" || got[1].Stage != "ask" || got[1].LastError != "context deadline exceeded" || got[1].LastErrorKind != string(ai.ErrorKindTimeout) {
t.Fatalf("unexpected run-b summary: %#v", got[1])
}
}
func TestRunStatusClassifiesOperationalErrorKinds(t *testing.T) {
tests := []struct {
name string
kind ai.ErrorKind
want string
}{
{name: "canceled", kind: ai.ErrorKindCanceled, want: "canceled"},
{name: "timeout", kind: ai.ErrorKindTimeout, want: "timeout"},
{name: "rate limited", kind: ai.ErrorKindRateLimited, want: "rate_limited"},
{name: "provider", kind: ai.ErrorKindProvider, want: "error"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := runStatus([]RunEvent{
{Kind: "run"},
{Kind: "error", Error: "failed", ErrorKind: string(tt.kind)},
})
if got != tt.want {
t.Fatalf("runStatus() = %q, want %q", got, tt.want)
}
})
}
}
func TestListRunSummariesWithOptionsFiltersAndLimits(t *testing.T) {
st := store.NewMemoryStore()
scoped := store.Scope(st, "agent", "runner")
events := []RunEvent{
{Time: time.Unix(0, 1), RunID: "run-old", Agent: "runner", Kind: "run"},
{Time: time.Unix(0, 2), RunID: "run-old", Agent: "runner", Kind: "done"},
{Time: time.Unix(0, 3), RunID: "run-new", Agent: "runner", TraceID: "abcdef1234567890", Kind: "run"},
{Time: time.Unix(0, 4), RunID: "run-new", Agent: "runner", Kind: "error", Error: "rate limit exceeded", ErrorKind: string(ai.ErrorKindRateLimited)},
}
for _, e := range events {
b, err := json.Marshal(e)
if err != nil {
t.Fatal(err)
}
if err := scoped.Write(&store.Record{Key: "runs/" + e.RunID + "/" + e.Time.Format("20060102150405.000000000") + "-" + e.Kind, Value: b}); err != nil {
t.Fatal(err)
}
}
got, err := ListRunSummariesWithOptions(st, "runner", RunListOptions{Status: "rate_limited", TraceID: "abcdef", Limit: 1})
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].RunID != "run-new" || got[0].Status != "rate_limited" {
t.Fatalf("filtered summaries = %#v", got)
}
}
type otelStreamModel struct{ opts ai.Options }
func (m *otelStreamModel) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&m.opts)
}
return nil
}
func (m *otelStreamModel) Options() ai.Options { return m.opts }
func (m *otelStreamModel) String() string { return "otelstream" }
func (m *otelStreamModel) Generate(context.Context, *ai.Request, ...ai.GenerateOption) (*ai.Response, error) {
return &ai.Response{Reply: "unused"}, nil
}
func (m *otelStreamModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
return &otelTestStream{chunks: []*ai.Response{{Reply: "one", Usage: ai.Usage{InputTokens: 1, OutputTokens: 2, TotalTokens: 3}}, {Reply: "two", Usage: ai.Usage{InputTokens: 1, OutputTokens: 4, TotalTokens: 5}}}}, nil
}
type otelTestStream struct {
chunks []*ai.Response
idx int
}
func (s *otelTestStream) Recv() (*ai.Response, error) {
if s.idx >= len(s.chunks) {
return nil, io.EOF
}
resp := s.chunks[s.idx]
s.idx++
return resp, nil
}
func (s *otelTestStream) Close() error { return nil }
func TestAgentOpenTelemetrySpansModelStream(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
st := store.NewMemoryStore()
a := New(Name("stream-runner"), Provider("oteltest"), Model("stream-model"), WithStore(st), TraceProvider(tp))
m := a.(*agentImpl).tracedModel(&otelStreamModel{opts: ai.Options{Model: "stream-model"}})
ctx := ai.WithRunInfo(context.Background(), ai.RunInfo{RunID: "stream-run-1", ParentID: "parent-run", Agent: "stream-runner", Attempt: 2, MaxAttempts: 3, Flow: "deploy", Step: "plan"})
stream, err := m.Stream(ctx, &ai.Request{Prompt: "stream"})
if err != nil {
t.Fatal(err)
}
for {
_, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatal(err)
}
}
if err := stream.Close(); err != nil {
t.Fatal(err)
}
spans := exp.GetSpans().Snapshots()
var sawStream bool
for _, s := range spans {
if s.Name() != spanNameModelStream {
continue
}
attrs := spanAttributes(s.Attributes())
if attrs[AttrRunID] != "stream-run-1" || attrs[AttrParentRunID] != "parent-run" || attrs[AttrAgentName] != "stream-runner" {
t.Fatalf("stream span missing run lineage: %#v", attrs)
}
if attrs[AttrFlowName] != "deploy" || attrs[AttrFlowStep] != "plan" {
t.Fatalf("stream span missing workflow attributes: %#v", attrs)
}
if attrs[AttrAttempt] != "2" || attrs[AttrMaxAttempts] != "3" || attrs[AttrTotalTokens] != "5" {
t.Fatalf("stream span missing attempt/usage attributes: %#v", attrs)
}
if !spanEventHasRunInfo(s.Events(), "agent.stream", "stream-run-1", "stream-runner") {
t.Fatalf("stream span missing stream event: %#v", s.Events())
}
sawStream = true
}
if !sawStream {
t.Fatalf("stream span not emitted; got %d spans", len(spans))
}
events, err := LoadRunEvents(st, "stream-runner", "stream-run-1")
if err != nil {
t.Fatal(err)
}
if len(events) != 1 || events[0].Kind != "stream" || events[0].TraceID == "" || events[0].SpanID == "" || events[0].Tokens.TotalTokens != 5 {
t.Fatalf("unexpected stream run event: %#v", events)
}
}
+70 -40
View File
@@ -2,7 +2,7 @@
// versions:
// protoc-gen-go v1.36.11
// protoc v3.21.12
// source: proto/agent.proto
// source: agent/proto/agent.proto
package agent
@@ -22,15 +22,17 @@ const (
)
type ChatRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
state protoimpl.MessageState `protogen:"open.v1"`
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
// parent_id correlates this chat with the workflow or agent run that dispatched it.
ParentId string `protobuf:"bytes,2,opt,name=parent_id,json=parentId,proto3" json:"parent_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ChatRequest) Reset() {
*x = ChatRequest{}
mi := &file_proto_agent_proto_msgTypes[0]
mi := &file_agent_proto_agent_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -42,7 +44,7 @@ func (x *ChatRequest) String() string {
func (*ChatRequest) ProtoMessage() {}
func (x *ChatRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[0]
mi := &file_agent_proto_agent_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -55,7 +57,7 @@ func (x *ChatRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ChatRequest.ProtoReflect.Descriptor instead.
func (*ChatRequest) Descriptor() ([]byte, []int) {
return file_proto_agent_proto_rawDescGZIP(), []int{0}
return file_agent_proto_agent_proto_rawDescGZIP(), []int{0}
}
func (x *ChatRequest) GetMessage() string {
@@ -65,18 +67,29 @@ func (x *ChatRequest) GetMessage() string {
return ""
}
func (x *ChatRequest) GetParentId() string {
if x != nil {
return x.ParentId
}
return ""
}
type ChatResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Reply string `protobuf:"bytes,1,opt,name=reply,proto3" json:"reply,omitempty"`
Agent string `protobuf:"bytes,2,opt,name=agent,proto3" json:"agent,omitempty"`
ToolCalls []*ToolCall `protobuf:"bytes,3,rep,name=tool_calls,json=toolCalls,proto3" json:"tool_calls,omitempty"`
state protoimpl.MessageState `protogen:"open.v1"`
Reply string `protobuf:"bytes,1,opt,name=reply,proto3" json:"reply,omitempty"`
Agent string `protobuf:"bytes,2,opt,name=agent,proto3" json:"agent,omitempty"`
ToolCalls []*ToolCall `protobuf:"bytes,3,rep,name=tool_calls,json=toolCalls,proto3" json:"tool_calls,omitempty"`
// run_id correlates this chat response with tool calls, traces, and run history.
RunId string `protobuf:"bytes,4,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"`
// parent_id is set when this response belongs to a delegated sub-agent run.
ParentId string `protobuf:"bytes,5,opt,name=parent_id,json=parentId,proto3" json:"parent_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ChatResponse) Reset() {
*x = ChatResponse{}
mi := &file_proto_agent_proto_msgTypes[1]
mi := &file_agent_proto_agent_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -88,7 +101,7 @@ func (x *ChatResponse) String() string {
func (*ChatResponse) ProtoMessage() {}
func (x *ChatResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[1]
mi := &file_agent_proto_agent_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -101,7 +114,7 @@ func (x *ChatResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ChatResponse.ProtoReflect.Descriptor instead.
func (*ChatResponse) Descriptor() ([]byte, []int) {
return file_proto_agent_proto_rawDescGZIP(), []int{1}
return file_agent_proto_agent_proto_rawDescGZIP(), []int{1}
}
func (x *ChatResponse) GetReply() string {
@@ -125,6 +138,20 @@ func (x *ChatResponse) GetToolCalls() []*ToolCall {
return nil
}
func (x *ChatResponse) GetRunId() string {
if x != nil {
return x.RunId
}
return ""
}
func (x *ChatResponse) GetParentId() string {
if x != nil {
return x.ParentId
}
return ""
}
type ToolCall struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
@@ -137,7 +164,7 @@ type ToolCall struct {
func (x *ToolCall) Reset() {
*x = ToolCall{}
mi := &file_proto_agent_proto_msgTypes[2]
mi := &file_agent_proto_agent_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -149,7 +176,7 @@ func (x *ToolCall) String() string {
func (*ToolCall) ProtoMessage() {}
func (x *ToolCall) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[2]
mi := &file_agent_proto_agent_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -162,7 +189,7 @@ func (x *ToolCall) ProtoReflect() protoreflect.Message {
// Deprecated: Use ToolCall.ProtoReflect.Descriptor instead.
func (*ToolCall) Descriptor() ([]byte, []int) {
return file_proto_agent_proto_rawDescGZIP(), []int{2}
return file_agent_proto_agent_proto_rawDescGZIP(), []int{2}
}
func (x *ToolCall) GetId() string {
@@ -193,18 +220,21 @@ func (x *ToolCall) GetResult() string {
return ""
}
var File_proto_agent_proto protoreflect.FileDescriptor
var File_agent_proto_agent_proto protoreflect.FileDescriptor
const file_proto_agent_proto_rawDesc = "" +
const file_agent_proto_agent_proto_rawDesc = "" +
"\n" +
"\x11proto/agent.proto\x12\x05agent\"'\n" +
"\x17agent/proto/agent.proto\x12\x05agent\"D\n" +
"\vChatRequest\x12\x18\n" +
"\amessage\x18\x01 \x01(\tR\amessage\"j\n" +
"\amessage\x18\x01 \x01(\tR\amessage\x12\x1b\n" +
"\tparent_id\x18\x02 \x01(\tR\bparentId\"\x9e\x01\n" +
"\fChatResponse\x12\x14\n" +
"\x05reply\x18\x01 \x01(\tR\x05reply\x12\x14\n" +
"\x05agent\x18\x02 \x01(\tR\x05agent\x12.\n" +
"\n" +
"tool_calls\x18\x03 \x03(\v2\x0f.agent.ToolCallR\ttoolCalls\"\\\n" +
"tool_calls\x18\x03 \x03(\v2\x0f.agent.ToolCallR\ttoolCalls\x12\x15\n" +
"\x06run_id\x18\x04 \x01(\tR\x05runId\x12\x1b\n" +
"\tparent_id\x18\x05 \x01(\tR\bparentId\"\\\n" +
"\bToolCall\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x14\n" +
@@ -214,24 +244,24 @@ const file_proto_agent_proto_rawDesc = "" +
"\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
file_agent_proto_agent_proto_rawDescOnce sync.Once
file_agent_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)))
func file_agent_proto_agent_proto_rawDescGZIP() []byte {
file_agent_proto_agent_proto_rawDescOnce.Do(func() {
file_agent_proto_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_agent_proto_agent_proto_rawDesc), len(file_agent_proto_agent_proto_rawDesc)))
})
return file_proto_agent_proto_rawDescData
return file_agent_proto_agent_proto_rawDescData
}
var file_proto_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
var file_proto_agent_proto_goTypes = []any{
var file_agent_proto_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
var file_agent_proto_agent_proto_goTypes = []any{
(*ChatRequest)(nil), // 0: agent.ChatRequest
(*ChatResponse)(nil), // 1: agent.ChatResponse
(*ToolCall)(nil), // 2: agent.ToolCall
}
var file_proto_agent_proto_depIdxs = []int32{
var file_agent_proto_agent_proto_depIdxs = []int32{
2, // 0: agent.ChatResponse.tool_calls:type_name -> agent.ToolCall
0, // 1: agent.Agent.Chat:input_type -> agent.ChatRequest
1, // 2: agent.Agent.Chat:output_type -> agent.ChatResponse
@@ -242,26 +272,26 @@ var file_proto_agent_proto_depIdxs = []int32{
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 {
func init() { file_agent_proto_agent_proto_init() }
func file_agent_proto_agent_proto_init() {
if File_agent_proto_agent_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_agent_proto_rawDesc), len(file_proto_agent_proto_rawDesc)),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_agent_proto_agent_proto_rawDesc), len(file_agent_proto_agent_proto_rawDesc)),
NumEnums: 0,
NumMessages: 3,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_proto_agent_proto_goTypes,
DependencyIndexes: file_proto_agent_proto_depIdxs,
MessageInfos: file_proto_agent_proto_msgTypes,
GoTypes: file_agent_proto_agent_proto_goTypes,
DependencyIndexes: file_agent_proto_agent_proto_depIdxs,
MessageInfos: file_agent_proto_agent_proto_msgTypes,
}.Build()
File_proto_agent_proto = out.File
file_proto_agent_proto_goTypes = nil
file_proto_agent_proto_depIdxs = nil
File_agent_proto_agent_proto = out.File
file_agent_proto_agent_proto_goTypes = nil
file_agent_proto_agent_proto_depIdxs = nil
}
+1 -1
View File
@@ -1,5 +1,5 @@
// Code generated by protoc-gen-micro. DO NOT EDIT.
// source: proto/agent.proto
// source: agent/proto/agent.proto
package agent
+8
View File
@@ -11,12 +11,20 @@ service Agent {
message ChatRequest {
string message = 1;
// parent_id correlates this chat with the workflow or agent run that dispatched it.
string parent_id = 2;
}
message ChatResponse {
string reply = 1;
string agent = 2;
repeated ToolCall tool_calls = 3;
// run_id correlates this chat response with tool calls, traces, and run history.
string run_id = 4;
// parent_id is set when this response belongs to a delegated sub-agent run.
string parent_id = 5;
}
message ToolCall {
+206
View File
@@ -3,10 +3,13 @@ package agent
import (
"context"
"errors"
"strings"
"testing"
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/store"
)
func TestAskCancellationAbortsPromptly(t *testing.T) {
@@ -74,4 +77,207 @@ func TestAskRetriesTransientErrorsThenSurfacesStructuredError(t *testing.T) {
if attempts != 2 {
t.Fatalf("model attempts = %d, want 2", attempts)
}
if !strings.Contains(err.Error(), "micro inspect agent <name> --status timeout") ||
!strings.Contains(err.Error(), "docs/guides/debugging-agents.md") {
t.Fatalf("Ask error = %q, want actionable timeout/debugging guidance", err.Error())
}
}
func TestAskRateLimitFailureSuggestsPreflightAndInspect(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
return nil, testStatusError{code: 429}
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("rate-limit-guidance"), ModelRetry(1, time.Millisecond))
_, err := a.Ask(context.Background(), "hello")
if err == nil {
t.Fatal("Ask succeeded, want rate-limit failure")
}
if !strings.Contains(err.Error(), "micro inspect agent <name> --status rate_limited") ||
!strings.Contains(err.Error(), "micro agent preflight") {
t.Fatalf("Ask error = %q, want inspect and preflight guidance", err.Error())
}
if ai.ClassifyError(err) != ai.ErrorKindRateLimited {
t.Fatalf("ClassifyError(wrapped error) = %q, want rate_limited", ai.ClassifyError(err))
}
}
func TestCanceledAskContextSkipsToolExecution(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
t.Fatal("missing tool handler")
}
canceled, cancel := context.WithCancel(ctx)
cancel()
res := opts.ToolHandler(canceled, ai.ToolCall{ID: "call-1", Name: toolPlan, Input: map[string]any{
"steps": []any{map[string]any{"task": "should not persist", "status": "pending"}},
}})
if !strings.Contains(res.Content, context.Canceled.Error()) {
t.Fatalf("tool result = %q, want cancellation error", res.Content)
}
return &ai.Response{Reply: "ok"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("cancel-tools"))
if _, err := a.Ask(context.Background(), "try a canceled tool"); err != nil {
t.Fatalf("Ask: %v", err)
}
if plan := a.loadPlan(); plan != "" {
t.Fatalf("plan persisted after canceled tool context: %q", plan)
}
}
func TestToolCallTimeoutPropagatesDeadlineToCustomTool(t *testing.T) {
var sawDeadline bool
a := newTestAgent(
Name("tool-timeout"),
ToolCallTimeout(10*time.Millisecond),
WithTool("slow", "slow tool", nil, func(ctx context.Context, input map[string]any) (string, error) {
if _, ok := ctx.Deadline(); ok {
sawDeadline = true
}
<-ctx.Done()
return "", ctx.Err()
}),
)
start := time.Now()
content := toolContent(a.toolHandler(), "slow", nil)
if !sawDeadline {
t.Fatal("custom tool did not receive a deadline")
}
if !strings.Contains(content, context.DeadlineExceeded.Error()) {
t.Fatalf("tool result = %q, want deadline exceeded", content)
}
if elapsed := time.Since(start); elapsed > 200*time.Millisecond {
t.Fatalf("tool call took %s, want bounded timeout", elapsed)
}
}
func TestAskCancellationDuringToolCallFailsRun(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
t.Fatal("missing tool handler")
}
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "cancel-self"})
if !strings.Contains(res.Content, context.Canceled.Error()) {
t.Fatalf("tool result = %q, want cancellation error", res.Content)
}
return &ai.Response{Reply: "should not succeed"}, nil
}
defer func() { fakeGen = nil }()
ctx, cancel := context.WithCancel(context.Background())
a := newTestAgent(
Name("cancel-during-tool"),
WithTool("cancel-self", "cancel the run context", nil, func(context.Context, map[string]any) (string, error) {
cancel()
return "", context.Canceled
}),
)
_, err := a.Ask(ctx, "cancel during tool")
if !errors.Is(err, context.Canceled) {
t.Fatalf("Ask error = %v, want context canceled", err)
}
}
func TestAskCheckpointRecordsTerminalOperationalFailureStatus(t *testing.T) {
tests := []struct {
name string
err error
want string
}{
{name: "canceled", err: context.Canceled, want: "canceled"},
{name: "timeout", err: context.DeadlineExceeded, want: "timeout"},
{name: "rate limited", err: testStatusError{code: 429}, want: "rate_limited"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "terminal-"+strings.ReplaceAll(tt.name, " ", "-"))
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
return nil, tt.err
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("terminal-"+strings.ReplaceAll(tt.name, " ", "-")), WithCheckpoint(cp))
_, err := a.Ask(context.Background(), "fail safely")
if err == nil {
t.Fatal("Ask succeeded, want failure")
}
runs, err := cp.List(context.Background())
if err != nil {
t.Fatalf("List: %v", err)
}
if len(runs) != 1 {
t.Fatalf("checkpointed runs = %d, want 1", len(runs))
}
if runs[0].Status != tt.want {
t.Fatalf("run status = %q, want %q", runs[0].Status, tt.want)
}
if len(runs[0].Steps) == 0 || runs[0].Steps[0].Status != tt.want {
t.Fatalf("step status = %#v, want %q", runs[0].Steps, tt.want)
}
if pending, err := Pending(context.Background(), a); err != nil || len(pending) != 0 {
t.Fatalf("Pending = %#v, %v; want no terminal run", pending, err)
}
})
}
}
type testStatusError struct {
code int
}
func (e testStatusError) Error() string { return "provider status error" }
func (e testStatusError) StatusCode() int { return e.code }
func TestToolRetryRetriesTransientToolErrorsThenSucceeds(t *testing.T) {
attempts := 0
a := newTestAgent(
Name("tool-retry-success"),
ToolRetry(3, time.Millisecond),
WithTool("flaky", "flaky tool", nil, func(context.Context, map[string]any) (string, error) {
attempts++
if attempts < 3 {
return "", context.DeadlineExceeded
}
return "ok", nil
}),
)
content := toolContent(a.toolHandler(), "flaky", nil)
if content != "ok" {
t.Fatalf("tool result = %q, want ok", content)
}
if attempts != 3 {
t.Fatalf("attempts = %d, want 3", attempts)
}
}
func TestToolRetryDoesNotRetryGuardrailRefusals(t *testing.T) {
attempts := 0
a := newTestAgent(
Name("tool-retry-refusal"),
MaxSteps(1),
ToolRetry(3, time.Millisecond),
WithTool("counted", "counted tool", nil, func(context.Context, map[string]any) (string, error) {
attempts++
return "ok", nil
}),
)
h := a.toolHandler()
_ = toolContent(h, "counted", nil)
content := toolContent(h, "counted", nil)
if !strings.Contains(content, "step limit reached") {
t.Fatalf("tool result = %q, want step-limit refusal", content)
}
if attempts != 1 {
t.Fatalf("attempts = %d, want only the allowed tool call to execute", attempts)
}
}
+316
View File
@@ -0,0 +1,316 @@
package agent
import (
"context"
"encoding/json"
"errors"
"io"
"strings"
"sync"
"github.com/google/uuid"
"go-micro.dev/v6/ai"
)
// StreamEventType identifies an event emitted by a tool-aware agent stream.
type StreamEventType string
const (
// StreamEventToolStart is emitted immediately before a tool call runs.
StreamEventToolStart StreamEventType = "tool_start"
// StreamEventToolEnd is emitted after a tool call returns or is refused.
StreamEventToolEnd StreamEventType = "tool_end"
// StreamEventToken carries a chunk of the final answer.
StreamEventToken StreamEventType = "token"
// StreamEventDone carries the completed agent response.
StreamEventDone StreamEventType = "done"
)
// StreamEvent is one event from StreamAsk.
type StreamEvent struct {
Type StreamEventType
Token string
ToolCall ai.ToolCall
Result ai.ToolResult
Response *Response
}
// AgentStream is a stream of tool execution events followed by final-answer chunks.
type AgentStream interface {
Recv() (*StreamEvent, error)
Close() error
}
// StreamAsk runs an agent Ask turn with tool start/end events and streams the final answer.
// It is additive for callers that hold the public Agent interface; concrete agents also
// expose the same method directly.
func StreamAsk(ctx context.Context, ag Agent, message string) (AgentStream, error) {
streamer, ok := ag.(interface {
StreamAsk(context.Context, string) (AgentStream, error)
})
if !ok {
return nil, errors.New("agent: StreamAsk unsupported by implementation")
}
return streamer.StreamAsk(ctx, message)
}
// ResumeStreamAsk resumes a checkpointed agent run and emits the same event
// shape as StreamAsk. Completed runs are streamed from the persisted response;
// unfinished runs continue from their checkpoint and emit tool events for any
// work that still needs to run. Tool calls already recorded as done in the
// checkpoint are reused by the agent checkpoint wrapper and are not re-executed.
func ResumeStreamAsk(ctx context.Context, ag Agent, runID string) (AgentStream, error) {
a, ok := ag.(*agentImpl)
if !ok {
return nil, errors.New("agent: ResumeStreamAsk unsupported by implementation")
}
return a.resumeStreamAsk(ctx, runID)
}
// StreamAsk runs tools like Ask, emits ToolStart/ToolEnd events as they execute,
// then emits chunks of the final answer followed by a Done event.
func (a *agentImpl) StreamAsk(ctx context.Context, message string) (AgentStream, error) {
events := make(chan *StreamEvent, 16)
done := make(chan struct{})
s := &agentStream{events: events, done: done}
go func() {
defer close(events)
defer close(done)
resp, err := a.askWithStreamEvents(ctx, message, events)
if err != nil {
s.setErr(err)
return
}
for _, tok := range splitStreamTokens(resp.Reply) {
if !sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToken, Token: tok}) {
return
}
}
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventDone, Response: resp})
}()
return s, nil
}
func (a *agentImpl) resumeStreamAsk(ctx context.Context, runID string) (AgentStream, error) {
events := make(chan *StreamEvent, 16)
done := make(chan struct{})
s := &agentStream{events: events, done: done}
go func() {
defer close(events)
defer close(done)
resp, err := a.resumeWithStreamEvents(ctx, runID, events)
if err != nil {
s.setErr(err)
return
}
for _, tok := range splitStreamTokens(resp.Reply) {
if !sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToken, Token: tok}) {
return
}
}
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventDone, Response: resp})
}()
return s, nil
}
func (a *agentImpl) askWithStreamEvents(ctx context.Context, message string, events chan<- *StreamEvent) (*Response, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.tools == nil {
a.tools = ai.NewTools(a.opts.Registry, ai.ToolClient(a.opts.Client))
}
base := a.toolHandler()
handler := func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToolStart, ToolCall: call})
result := base(ctx, call)
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToolEnd, ToolCall: call, Result: result})
return result
}
a.setupWithToolHandler(handler)
defer a.setupWithToolHandler(nil)
return a.askLocked(ctx, uuid.New().String(), message, a.parentRunID, nil, true)
}
func (a *agentImpl) resumeWithStreamEvents(ctx context.Context, runID string, events chan<- *StreamEvent) (*Response, error) {
if a.opts.Checkpoint == nil {
return nil, errors.New("agent: ResumeStreamAsk requires a checkpoint")
}
run, ok, err := a.opts.Checkpoint.Load(ctx, runID)
if err != nil {
return nil, err
}
if !ok {
return nil, errors.New("agent: checkpointed run not found")
}
if run.Status == "done" {
var resp Response
if err := json.Unmarshal(run.State.Data, &resp); err != nil {
return nil, err
}
return &resp, nil
}
if terminalAgentRunStatus(run.Status) {
return nil, errors.New("agent: checkpointed run is terminal with status " + run.Status)
}
a.mu.Lock()
defer a.mu.Unlock()
if a.tools == nil {
a.tools = ai.NewTools(a.opts.Registry, ai.ToolClient(a.opts.Client))
}
base := a.toolHandler()
handler := func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToolStart, ToolCall: call})
result := base(ctx, call)
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToolEnd, ToolCall: call, Result: result})
return result
}
a.setupWithToolHandler(handler)
defer a.setupWithToolHandler(nil)
if run.Status == "paused" {
if run.State.Stage == agentInputStep {
return nil, errors.New("agent: checkpointed run is input-required; resume with ResumeInput")
}
run.Status = "running"
run.State.Stage = agentAskStep
}
return a.askLocked(ctx, run.ID, string(run.State.Data), run.ParentID, &run, false)
}
type agentStreamAdapter struct {
stream AgentStream
}
type memoryRecordingStream struct {
stream ai.Stream
memory Memory
mu sync.Mutex
chunks []string
closed bool
}
func (s *memoryRecordingStream) Recv() (*ai.Response, error) {
resp, err := s.stream.Recv()
if resp != nil && resp.Reply != "" {
s.mu.Lock()
s.chunks = append(s.chunks, resp.Reply)
s.mu.Unlock()
}
if errors.Is(err, io.EOF) {
s.recordAssistant()
}
return resp, err
}
func (s *memoryRecordingStream) Close() error {
s.recordAssistant()
return s.stream.Close()
}
func (s *memoryRecordingStream) recordAssistant() {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return
}
s.closed = true
if reply := strings.Join(s.chunks, ""); reply != "" {
s.memory.Add("assistant", reply)
}
}
func (s *agentStreamAdapter) Recv() (*ai.Response, error) {
for {
event, err := s.stream.Recv()
if err != nil {
return nil, err
}
if event == nil {
continue
}
switch event.Type {
case StreamEventToken:
if event.Token == "" {
continue
}
return &ai.Response{Reply: event.Token}, nil
case StreamEventDone:
return nil, io.EOF
}
}
}
func (s *agentStreamAdapter) Close() error {
return s.stream.Close()
}
func (a *agentImpl) streamAskAI(ctx context.Context, message string) (ai.Stream, error) {
stream, err := a.StreamAsk(ctx, message)
if err != nil {
return nil, err
}
return &agentStreamAdapter{stream: stream}, nil
}
type agentStream struct {
events <-chan *StreamEvent
done <-chan struct{}
mu sync.Mutex
err error
}
func (s *agentStream) Recv() (*StreamEvent, error) {
ev, ok := <-s.events
if ok {
return ev, nil
}
s.mu.Lock()
defer s.mu.Unlock()
if s.err != nil {
return nil, s.err
}
return nil, io.EOF
}
func (s *agentStream) Close() error {
<-s.done
return nil
}
func (s *agentStream) setErr(err error) {
s.mu.Lock()
defer s.mu.Unlock()
s.err = err
}
func sendStreamEvent(ctx context.Context, events chan<- *StreamEvent, ev *StreamEvent) bool {
select {
case events <- ev:
return true
case <-ctx.Done():
return false
}
}
func splitStreamTokens(reply string) []string {
if reply == "" {
return nil
}
parts := strings.Fields(reply)
if len(parts) == 0 {
return []string{reply}
}
out := make([]string, 0, len(parts))
for i, part := range parts {
if i > 0 {
part = " " + part
}
out = append(out, part)
}
return out
}
+221
View File
@@ -0,0 +1,221 @@
package agent
import (
"context"
"errors"
"io"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/store"
)
func TestStreamAskEmitsToolEventsAndFinalTokens(t *testing.T) {
calls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
t.Fatal("StreamAsk must configure a tool handler")
}
calls++
result := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "echo", Input: map[string]any{"text": "hello"}})
return &ai.Response{
Reply: "planning",
Answer: "final answer",
ToolCalls: []ai.ToolCall{{ID: "call-1", Name: "echo", Input: map[string]any{"text": "hello"}, Result: result.Content}},
}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("streamer"), WithTool("echo", "echo text", nil, func(ctx context.Context, input map[string]any) (string, error) {
return input["text"].(string), nil
}))
stream, err := a.StreamAsk(context.Background(), "say hello")
if err != nil {
t.Fatalf("StreamAsk: %v", err)
}
var types []StreamEventType
var tokens string
var done *Response
for {
event, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("Recv: %v", err)
}
types = append(types, event.Type)
if event.Type == StreamEventToken {
tokens += event.Token
}
if event.Type == StreamEventDone {
done = event.Response
}
}
want := []StreamEventType{StreamEventToolStart, StreamEventToolEnd, StreamEventToken, StreamEventToken, StreamEventToken, StreamEventDone}
if len(types) != len(want) {
t.Fatalf("event types = %v, want %v", types, want)
}
for i := range want {
if types[i] != want[i] {
t.Fatalf("event types = %v, want %v", types, want)
}
}
if tokens != "planning final answer" {
t.Fatalf("tokens = %q", tokens)
}
if done == nil || done.Reply != "planning\n\nfinal answer" {
t.Fatalf("done response = %#v", done)
}
if calls != 1 {
t.Fatalf("Generate calls = %d, want 1", calls)
}
}
func TestStreamAskHelperRejectsUnsupportedAgent(t *testing.T) {
_, err := StreamAsk(context.Background(), unsupportedAgent{}, "hello")
if err == nil {
t.Fatal("StreamAsk helper should reject unsupported implementations")
}
}
func TestAgentStreamUsesProviderStreamingAndRecordsAssistantMemory(t *testing.T) {
var sawRequest bool
fakeStream = func(ctx context.Context, opts ai.Options, req *ai.Request) (ai.Stream, error) {
sawRequest = true
if req.Prompt != "stream the answer" {
t.Fatalf("Prompt = %q, want stream the answer", req.Prompt)
}
if len(req.Messages) != 1 || req.Messages[0].Role != "user" || req.Messages[0].Content != "stream the answer" {
t.Fatalf("Messages = %#v, want current user turn in memory", req.Messages)
}
return &sliceStream{chunks: []string{"hel", "lo"}}, nil
}
defer func() { fakeStream = nil }()
a := newTestAgent(Name("provider-stream"))
stream, err := a.Stream(context.Background(), "stream the answer")
if err != nil {
t.Fatalf("Stream: %v", err)
}
var reply string
for {
chunk, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("Recv: %v", err)
}
reply += chunk.Reply
}
if err := stream.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if !sawRequest {
t.Fatal("provider Stream was not called")
}
if reply != "hello" {
t.Fatalf("reply = %q, want hello", reply)
}
got := a.mem.Messages()
if len(got) != 2 || got[0].Role != "user" || got[0].Content != "stream the answer" || got[1].Role != "assistant" || got[1].Content != "hello" {
t.Fatalf("memory = %#v, want user turn and streamed assistant reply", got)
}
}
func TestResumeStreamAskDoesNotReplayCompletedTool(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewStore(), "stream-resume-agent")
toolRuns := 0
first := true
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler != nil {
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "charge", Input: map[string]any{"order": "42"}})
if res.Content != "charged" {
t.Fatalf("tool result = %q, want charged", res.Content)
}
}
if first {
first = false
return nil, errors.New("stream disconnected after tool")
}
return &ai.Response{Reply: "finished from streamed checkpoint"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("stream-resume-agent"), WithCheckpoint(cp),
WithTool("charge", "charge once", nil, func(context.Context, map[string]any) (string, error) {
toolRuns++
return "charged", nil
}))
stream, err := a.StreamAsk(ctx, "charge order 42")
if err != nil {
t.Fatalf("StreamAsk: %v", err)
}
for {
_, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
break
}
}
if toolRuns != 1 {
t.Fatalf("tool executions after failed StreamAsk = %d, want 1", toolRuns)
}
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 {
t.Fatalf("Pending returned %d runs, want 1", len(runs))
}
resumed, err := ResumeStreamAsk(ctx, a, runs[0].ID)
if err != nil {
t.Fatalf("ResumeStreamAsk: %v", err)
}
var toolEvents int
var done *Response
for {
event, err := resumed.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("resumed Recv: %v", err)
}
if event.Type == StreamEventToolStart || event.Type == StreamEventToolEnd {
toolEvents++
}
if event.Type == StreamEventDone {
done = event.Response
}
}
if toolRuns != 1 {
t.Fatalf("tool executions after ResumeStreamAsk = %d, want completed tool was not replayed", toolRuns)
}
if toolEvents != 2 {
t.Fatalf("resumed tool events = %d, want start/end for replayed checkpoint result", toolEvents)
}
if done == nil || done.Reply != "finished from streamed checkpoint" || done.RunID != runs[0].ID {
t.Fatalf("done response = %#v", done)
}
}
type unsupportedAgent struct{}
func (unsupportedAgent) Name() string { return "unsupported" }
func (unsupportedAgent) Init(...Option) {}
func (unsupportedAgent) Options() Options { return Options{} }
func (unsupportedAgent) Ask(context.Context, string) (*Response, error) { return nil, nil }
func (unsupportedAgent) Stream(context.Context, string) (ai.Stream, error) { return nil, nil }
func (unsupportedAgent) Run() error { return nil }
func (unsupportedAgent) Stop() error { return nil }
func (unsupportedAgent) String() string { return "unsupported" }
+260
View File
@@ -0,0 +1,260 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
"go-micro.dev/v6/ai"
)
var fencedJSONBlock = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
var taggedToolCallBlock = regexp.MustCompile(`(?s)<[^<>]*(?:tool_call|tool_calls|function=)[^<>]*>(.*?)</[^<>]*>`)
var singleTaggedToolCall = regexp.MustCompile(`(?s)<(tool_call\b[^<>]*|[^<>]*function=[^<>]*)>(.*?)</[^<>]*>`)
type textToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Tool string `json:"tool"`
Input map[string]any `json:"input"`
Arguments map[string]any `json:"arguments"`
}
// executeTextToolCalls is a compatibility fallback for providers that return a
// tool call as text JSON instead of a structured tool_calls field. It only runs
// calls whose names match the tools offered to the model, so ordinary JSON
// answers are left untouched.
func (a *agentImpl) executeTextToolCalls(ctx context.Context, reply string, tools []ai.Tool) ([]ai.ToolCall, string, bool) {
calls := parseTextToolCalls(reply, tools)
if len(calls) == 0 {
return nil, "", false
}
handler := a.toolHandler()
results := make([]string, 0, len(calls))
for i := range calls {
result := handler(ctx, calls[i])
calls[i].Result = result.Content
if result.Refused != "" {
calls[i].Error = result.Refused
}
if result.Content != "" {
results = append(results, result.Content)
}
}
return calls, strings.Join(results, "\n"), true
}
// executeAdditionalTextToolCalls runs text-encoded tool calls that accompany a
// structured tool_calls response. Some OpenAI-compatible providers can mix the
// two forms in a single assistant turn: for example, emitting a native
// conformance_echo call while rendering a follow-up guarded delegate call as
// <tool_call name="delegate">...</tool_call> text. Keep this fallback additive
// and de-duplicate calls already represented in the structured tool_calls list.
func (a *agentImpl) executeAdditionalTextToolCalls(ctx context.Context, reply string, tools []ai.Tool, existing []ai.ToolCall) ([]ai.ToolCall, string, bool) {
calls := parseTextToolCalls(reply, tools)
if len(calls) == 0 {
return nil, "", false
}
seen := map[string]bool{}
for _, call := range existing {
seen[textToolCallKey(call)] = true
}
handler := a.toolHandler()
out := make([]ai.ToolCall, 0, len(calls))
results := make([]string, 0, len(calls))
for i := range calls {
if seen[textToolCallKey(calls[i])] {
continue
}
result := handler(ctx, calls[i])
calls[i].Result = result.Content
if result.Refused != "" {
calls[i].Error = result.Refused
}
if result.Content != "" {
results = append(results, result.Content)
}
out = append(out, calls[i])
}
return out, strings.Join(results, "\n"), len(out) > 0
}
func textToolCallKey(call ai.ToolCall) string {
b, _ := json.Marshal(call.Input)
return call.Name + "\x00" + string(b)
}
func parseTextToolCalls(text string, tools []ai.Tool) []ai.ToolCall {
allowed := textToolNames(tools)
if len(allowed) == 0 {
return nil
}
if calls := decodeTaggedTextToolCalls(text, allowed); len(calls) > 0 {
return calls
}
for _, candidate := range jsonCandidates(text) {
if calls := decodeTextToolCalls(candidate, allowed); len(calls) > 0 {
return calls
}
}
return nil
}
func textToolNames(tools []ai.Tool) map[string]string {
allowed := map[string]string{}
for _, tool := range tools {
addTextToolName(allowed, tool.Name, tool.Name)
if tool.OriginalName != "" {
addTextToolName(allowed, tool.OriginalName, tool.Name)
}
}
return allowed
}
func addTextToolName(allowed map[string]string, name, canonical string) {
if name == "" || canonical == "" {
return
}
allowed[name] = canonical
// Some OpenAI-compatible models describe an idempotent Add endpoint as a
// creation action and emit the otherwise-correct service tool with a Create
// suffix in text-only tool-call markup. Keep the fallback bounded by the
// offered service tool prefix so ordinary unknown tools remain ignored.
for _, suffix := range []string{"_Add", ".Add"} {
if strings.HasSuffix(name, suffix) {
allowed[strings.TrimSuffix(name, suffix)+strings.Replace(suffix, "Add", "Create", 1)] = canonical
}
}
}
func jsonCandidates(text string) []string {
trimmed := strings.TrimSpace(text)
var out []string
if trimmed != "" {
out = append(out, trimmed)
}
for _, match := range fencedJSONBlock.FindAllStringSubmatch(text, -1) {
if len(match) > 1 {
out = append(out, strings.TrimSpace(match[1]))
}
}
for _, match := range taggedToolCallBlock.FindAllStringSubmatch(text, -1) {
if len(match) > 1 {
out = append(out, strings.TrimSpace(match[1]))
}
}
if start, end := strings.IndexAny(text, "[{"), strings.LastIndexAny(text, "]}"); start >= 0 && end > start {
out = append(out, strings.TrimSpace(text[start:end+1]))
}
return out
}
func decodeTextToolCalls(candidate string, allowed map[string]string) []ai.ToolCall {
var root any
if err := json.Unmarshal([]byte(candidate), &root); err != nil {
return nil
}
return collectTextToolCalls(root, allowed)
}
func collectTextToolCalls(v any, allowed map[string]string) []ai.ToolCall {
switch x := v.(type) {
case []any:
var out []ai.ToolCall
for _, item := range x {
out = append(out, collectTextToolCalls(item, allowed)...)
}
return out
case map[string]any:
if nested, ok := firstNestedToolCalls(x); ok {
return collectTextToolCalls(nested, allowed)
}
call := mapToTextToolCall(x)
name := call.Name
if name == "" {
name = call.Tool
}
input := call.Input
if input == nil {
input = call.Arguments
}
if name == "" || allowed[name] == "" || input == nil {
return nil
}
id := call.ID
if id == "" {
id = fmt.Sprintf("text-call-%s", strings.ReplaceAll(name, ".", "_"))
}
return []ai.ToolCall{{ID: id, Name: allowed[name], Input: input}}
default:
return nil
}
}
func decodeTaggedTextToolCalls(text string, allowed map[string]string) []ai.ToolCall {
var out []ai.ToolCall
for _, match := range singleTaggedToolCall.FindAllStringSubmatch(text, -1) {
if len(match) < 3 {
continue
}
tag, body := match[1], strings.TrimSpace(match[2])
if calls := decodeTextToolCalls(body, allowed); len(calls) > 0 {
out = append(out, calls...)
continue
}
if calls := decodeTaggedTextToolCalls(body, allowed); len(calls) > 0 {
out = append(out, calls...)
continue
}
name := taggedToolName(tag)
if name == "" || allowed[name] == "" {
continue
}
var input map[string]any
if err := json.Unmarshal([]byte(body), &input); err != nil || input == nil {
continue
}
out = append(out, ai.ToolCall{
ID: fmt.Sprintf("text-call-%s", strings.ReplaceAll(name, ".", "_")),
Name: allowed[name],
Input: input,
})
}
return out
}
func taggedToolName(tag string) string {
for _, marker := range []string{"function=", "name=", "tool="} {
if idx := strings.Index(tag, marker); idx >= 0 {
name := strings.TrimSpace(tag[idx+len(marker):])
name = strings.Trim(name, `"'`)
if end := strings.IndexAny(name, " \t\r\n>"); end >= 0 {
name = name[:end]
}
return strings.Trim(name, `"'`)
}
}
return ""
}
func firstNestedToolCalls(m map[string]any) (any, bool) {
for _, key := range []string{"tool_calls", "toolCalls", "calls"} {
if v, ok := m[key]; ok {
return v, true
}
}
return nil, false
}
func mapToTextToolCall(m map[string]any) textToolCall {
b, _ := json.Marshal(m)
var call textToolCall
_ = json.Unmarshal(b, &call)
return call
}
+58
View File
@@ -0,0 +1,58 @@
package agent
import (
"testing"
"go-micro.dev/v6/ai"
)
func TestParseTextToolCallsMiniMaxTaggedMarkup(t *testing.T) {
tools := []ai.Tool{{Name: "task_TaskService_Add"}}
reply := `<tool_calls>
<tool_call>{"name":"task_TaskService_Add","arguments":{"title":"Design"}}</tool_call>
<tool_call>{"name":"task_TaskService_Add","arguments":{"title":"Build"}}</tool_call>
<tool_call>{"name":"task_TaskService_Add","arguments":{"title":"Ship"}}</tool_call>
</tool_calls>`
calls := parseTextToolCalls(reply, tools)
if len(calls) != 3 {
t.Fatalf("parseTextToolCalls returned %d calls, want 3: %+v", len(calls), calls)
}
for i, want := range []string{"Design", "Build", "Ship"} {
if calls[i].Name != "task_TaskService_Add" {
t.Fatalf("call %d name = %q, want task_TaskService_Add", i, calls[i].Name)
}
if got := calls[i].Input["title"]; got != want {
t.Fatalf("call %d title = %v, want %q", i, got, want)
}
}
}
func TestParseTextToolCallsFunctionTaggedMarkup(t *testing.T) {
tools := []ai.Tool{{Name: "task_TaskService_Add"}}
reply := `<function=task_TaskService_Add>{"title":"Design"}</function>`
calls := parseTextToolCalls(reply, tools)
if len(calls) != 1 {
t.Fatalf("parseTextToolCalls returned %d calls, want 1: %+v", len(calls), calls)
}
if got := calls[0].Input["title"]; got != "Design" {
t.Fatalf("title = %v, want Design", got)
}
}
func TestParseTextToolCallsCreateAliasForAddTool(t *testing.T) {
tools := []ai.Tool{{Name: "task_TaskService_Add", OriginalName: "task.TaskService.Add"}}
reply := `<tool_call>{"name":"task_TaskService_Create","arguments":{"title":"Design"}}</tool_call>`
calls := parseTextToolCalls(reply, tools)
if len(calls) != 1 {
t.Fatalf("parseTextToolCalls returned %d calls, want 1: %+v", len(calls), calls)
}
if calls[0].Name != "task_TaskService_Add" {
t.Fatalf("call name = %q, want canonical task_TaskService_Add", calls[0].Name)
}
if got := calls[0].Input["title"]; got != "Design" {
t.Fatalf("title = %v, want Design", got)
}
}
+30
View File
@@ -188,6 +188,22 @@ type Response struct {
- `ToolCalls`: List of tools the model requested (if any)
- `Answer`: The final answer after tools are executed (only set if ToolHandler is provided)
## Provider capability matrix
The CLI can print the provider capabilities registered in the current build:
```bash
micro ai providers
```
For automation and docs generation, emit the same matrix as stable JSON:
```bash
micro ai providers --json
```
It reports support from Go Micro's provider registry, so the matrix reflects the model, image, and video interfaces available to this binary rather than external provider marketing claims.
## Supported Providers
### Anthropic Claude
@@ -284,6 +300,20 @@ Default base URL: `https://api.atlascloud.ai`
Atlas Cloud is an enterprise AI infrastructure platform offering high-performance LLM APIs. It exposes an OpenAI-compatible chat completions endpoint with tool calling support.
### MiniMax
```go
m := ai.New("minimax",
ai.WithAPIKey("your-key"),
ai.WithModel("MiniMax-M3"), // default
)
```
Default model: `MiniMax-M3`
Default base URL: `https://api.minimax.io`
MiniMax offers its flagship MiniMax-M3 model via an OpenAI-compatible chat completions endpoint.
## Auto-Detection
Use `AutoDetectProvider()` to detect the provider from a base URL:
+135 -11
View File
@@ -2,6 +2,7 @@
package anthropic
import (
"bufio"
"bytes"
"context"
"encoding/json"
@@ -17,6 +18,7 @@ func init() {
ai.Register("anthropic", func(opts ...ai.Option) ai.Model {
return NewProvider(opts...)
})
ai.RegisterStream("anthropic")
}
// Provider implements the ai.Model interface for Anthropic Claude
@@ -77,11 +79,9 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
// Build initial request
apiReq := map[string]any{
"model": p.opts.Model,
"max_tokens": 8192,
"max_tokens": anthropicMaxTokens(p.opts),
"system": req.SystemPrompt,
"messages": []map[string]any{
{"role": "user", "content": req.Prompt},
},
"messages": threadAnthropicMessages(req),
}
if len(anthropicTools) > 0 {
@@ -101,10 +101,9 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
// 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)},
}
messages := append(threadAnthropicMessages(req),
map[string]any{"role": "assistant", "content": cleanContent(rawContent)},
)
pendingCalls := resp.ToolCalls
@@ -127,7 +126,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
followUpReq := map[string]any{
"model": p.opts.Model,
"max_tokens": 8192,
"max_tokens": anthropicMaxTokens(p.opts),
"system": req.SystemPrompt,
"messages": messages,
}
@@ -159,9 +158,113 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
return resp, nil
}
// Stream generates a streaming response (not yet implemented)
// Stream generates a streaming response from Anthropic's Messages SSE API.
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")
apiReq := map[string]any{
"model": p.opts.Model,
"max_tokens": anthropicMaxTokens(p.opts),
"system": req.SystemPrompt,
"messages": threadAnthropicMessages(req),
"stream": true,
}
reqBody, err := json.Marshal(apiReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/messages"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create stream request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "text/event-stream")
httpReq.Header.Set("x-api-key", p.opts.APIKey)
httpReq.Header.Set("anthropic-version", "2023-06-01")
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("stream API request failed: %w", err)
}
if httpResp.StatusCode != http.StatusOK {
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
return nil, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(respBody))
}
return &streamReader{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
}
type streamReader struct {
body io.ReadCloser
scanner *bufio.Scanner
closed bool
}
func (s *streamReader) Recv() (*ai.Response, error) {
for s.scanner.Scan() {
line := strings.TrimSpace(s.scanner.Text())
if line == "" || strings.HasPrefix(line, ":") || strings.HasPrefix(line, "event:") {
continue
}
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
var chunk struct {
Type string `json:"type"`
Delta struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"delta"`
Message struct {
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
} `json:"message"`
Usage *struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
}
switch chunk.Type {
case "content_block_delta":
if chunk.Delta.Type == "text_delta" && chunk.Delta.Text != "" {
return &ai.Response{Reply: chunk.Delta.Text}, nil
}
case "message_start":
if chunk.Message.Usage.InputTokens > 0 || chunk.Message.Usage.OutputTokens > 0 {
return &ai.Response{Usage: usage(chunk.Message.Usage.InputTokens, chunk.Message.Usage.OutputTokens)}, nil
}
case "message_delta":
if chunk.Usage != nil {
return &ai.Response{Usage: usage(chunk.Usage.InputTokens, chunk.Usage.OutputTokens)}, nil
}
case "message_stop":
return nil, io.EOF
case "error":
return nil, fmt.Errorf("anthropic stream error: %s", data)
}
}
if err := s.scanner.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}
func (s *streamReader) Close() error {
if s.closed {
return nil
}
s.closed = true
return s.body.Close()
}
func usage(input, output int) ai.Usage {
return ai.Usage{InputTokens: input, OutputTokens: output, TotalTokens: input + output}
}
// callAPI makes an HTTP request to the Anthropic API
@@ -270,3 +373,24 @@ func cleanContent(raw any) any {
}
return cleaned
}
// threadAnthropicMessages builds the Anthropic messages array from the
// conversation history (req.Messages) followed by the current prompt. The
// system prompt is sent separately via the top-level "system" field.
func threadAnthropicMessages(req *ai.Request) []map[string]any {
msgs := make([]map[string]any, 0, len(req.Messages)+1)
for _, m := range req.Messages {
msgs = append(msgs, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
msgs = append(msgs, map[string]any{"role": "user", "content": req.Prompt})
}
return msgs
}
func anthropicMaxTokens(o ai.Options) int {
if o.MaxTokens > 0 {
return o.MaxTokens
}
return 8192
}
+62 -5
View File
@@ -2,6 +2,11 @@ package anthropic
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go-micro.dev/v6/ai"
@@ -80,15 +85,67 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
}
}
func TestProvider_Stream_NotImplemented(t *testing.T) {
p := NewProvider()
func TestProvider_Stream(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/messages" {
t.Fatalf("path = %q, want /v1/messages", r.URL.Path)
}
if got := r.Header.Get("Accept"); got != "text/event-stream" {
t.Fatalf("Accept = %q, want text/event-stream", got)
}
if got := r.Header.Get("x-api-key"); got != "test-key" {
t.Fatalf("x-api-key = %q, want test-key", got)
}
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), `"stream":true`) {
t.Fatalf("request body %s does not enable streaming", string(body))
}
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("event: message_start\n"))
_, _ = w.Write([]byte(`data: {"type":"message_start","message":{"usage":{"input_tokens":2}}}` + "\n\n"))
_, _ = w.Write([]byte("event: content_block_delta\n"))
_, _ = w.Write([]byte(`data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hel"}}` + "\n\n"))
_, _ = w.Write([]byte("event: content_block_delta\n"))
_, _ = w.Write([]byte(`data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"lo"}}` + "\n\n"))
_, _ = w.Write([]byte("event: message_delta\n"))
_, _ = w.Write([]byte(`data: {"type":"message_delta","usage":{"output_tokens":3}}` + "\n\n"))
_, _ = w.Write([]byte("event: message_stop\n"))
_, _ = w.Write([]byte(`data: {"type":"message_stop"}` + "\n\n"))
}))
defer ts.Close()
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
req := &ai.Request{
Prompt: "Hello",
}
_, err := p.Stream(context.Background(), req)
if err == nil {
t.Error("Expected error for unimplemented streaming, got nil")
stream, err := p.Stream(context.Background(), req)
if err != nil {
t.Fatalf("Stream failed: %v", err)
}
defer stream.Close()
var reply strings.Builder
var usage ai.Usage
for {
chunk, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("Recv failed: %v", err)
}
reply.WriteString(chunk.Reply)
if chunk.Usage.TotalTokens > 0 {
usage = chunk.Usage
}
}
if got := reply.String(); got != "hello" {
t.Fatalf("reply = %q, want hello", got)
}
if usage.TotalTokens != 3 {
t.Fatalf("usage = %+v, want total 3", usage)
}
}
+297 -32
View File
@@ -20,12 +20,14 @@
package atlascloud
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
@@ -42,6 +44,7 @@ func init() {
ai.RegisterVideo("atlascloud", func(opts ...ai.Option) ai.VideoModel {
return NewProvider(opts...)
})
ai.RegisterStream("atlascloud")
}
// Provider implements the ai.Model interface for Atlas Cloud.
@@ -49,12 +52,28 @@ type Provider struct {
opts ai.Options
}
type atlasToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
// NewProvider creates a new Atlas Cloud provider.
func NewProvider(opts ...ai.Option) *Provider {
options := ai.NewOptions(opts...)
if options.Model == "" {
options.Model = "deepseek-ai/DeepSeek-V3-0324"
// Allow the chat model to be selected via the ATLASCLOUD_MODEL env var
// (e.g. to run CI conformance against a stronger tool-use model) without
// a code change; fall back to a sensible default otherwise.
if m := os.Getenv("ATLASCLOUD_MODEL"); m != "" {
options.Model = m
} else {
options.Model = "deepseek-ai/DeepSeek-V3-0324"
}
}
if options.BaseURL == "" {
options.BaseURL = "https://api.atlascloud.ai"
@@ -74,36 +93,31 @@ 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,
},
},
})
}
tools := atlascloudTools(req.Tools)
messages := []map[string]any{
{"role": "system", "content": req.SystemPrompt},
{"role": "user", "content": req.Prompt},
}
for _, m := range req.Messages {
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
}
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
}
if p.opts.MaxTokens > 0 {
apiReq["max_tokens"] = p.opts.MaxTokens
}
if len(tools) > 0 {
apiReq["tools"] = tools
}
resp, rawMessage, err := p.callAPI(ctx, apiReq)
resp, rawMessage, err := p.callAPI(ctx, "chat", apiReq)
if err != nil {
return nil, err
}
@@ -113,6 +127,8 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
}
if p.opts.ToolHandler != nil {
allToolCalls := append([]ai.ToolCall(nil), resp.ToolCalls...)
var toolResults []string
followUpMessages := append(messages, map[string]any{
"role": "assistant",
"content": rawMessage["content"],
@@ -121,6 +137,9 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
for _, tc := range resp.ToolCalls {
content := p.opts.ToolHandler(ctx, tc).Content
if content != "" {
toolResults = append(toolResults, content)
}
followUpMessages = append(followUpMessages, map[string]any{
"role": "tool",
"tool_call_id": tc.ID,
@@ -132,21 +151,164 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
"model": p.opts.Model,
"messages": followUpMessages,
}
if len(tools) > 0 {
// Keep the tool schema available during the follow-up turn. Minimax
// models behind Atlas Cloud sometimes call one required tool, inspect
// that result, and then issue a second tool call (for example a guarded
// delegate conformance check) instead of completing immediately.
followUpReq["tools"] = tools
}
followUpResp, _, err := p.callAPI(ctx, followUpReq)
if err == nil && followUpResp.Reply != "" {
resp.Answer = followUpResp.Reply
followUpResp, _, err := p.callAPI(ctx, "tool-follow-up", followUpReq)
if err != nil {
return nil, err
}
if len(followUpResp.ToolCalls) > 0 {
for i := range followUpResp.ToolCalls {
result := p.opts.ToolHandler(ctx, followUpResp.ToolCalls[i])
if result.Refused != "" {
followUpResp.ToolCalls[i].Error = result.Refused
}
if result.Content != "" {
followUpResp.ToolCalls[i].Result = result.Content
toolResults = append(toolResults, result.Content)
}
}
allToolCalls = append(allToolCalls, followUpResp.ToolCalls...)
resp.ToolCalls = allToolCalls
}
if followUpResp.Reply != "" {
if strings.Contains(followUpResp.Reply, "<tool_call") || strings.Contains(followUpResp.Reply, "function=") {
// Preserve follow-up assistant content as Reply, not Answer, when
// it may contain a text-encoded tool call. The agent harness
// inspects Reply for text fallback calls after Generate returns,
// which covers AtlasCloud/minimax turns that emit a second
// required call (for example guarded delegate) as markup instead
// of native tool_calls.
resp.Reply = followUpResp.Reply
} else {
resp.Answer = followUpResp.Reply
}
} else if len(toolResults) > 0 {
resp.Answer = strings.Join(toolResults, "\n")
}
}
return resp, nil
}
// Stream generates a streaming response from Atlas Cloud's OpenAI-compatible
// chat completions endpoint, emitting content deltas as they arrive.
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("streaming not yet implemented for atlascloud provider")
if len(req.Tools) > 0 {
return nil, fmt.Errorf("%w: atlascloud streaming does not expose tools", ai.ErrStreamingUnsupported)
}
messages := []map[string]any{
{"role": "system", "content": req.SystemPrompt},
}
for _, m := range req.Messages {
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
}
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
"stream": true,
"stream_options": map[string]any{"include_usage": true},
}
if p.opts.MaxTokens > 0 {
apiReq["max_tokens"] = p.opts.MaxTokens
}
reqBody, err := json.Marshal(apiReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create stream request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "text/event-stream")
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("stream API request failed: %w", err)
}
if httpResp.StatusCode != http.StatusOK {
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
return nil, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(respBody))
}
return &atlasStream{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
}
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
type atlasStream struct {
body io.ReadCloser
scanner *bufio.Scanner
closed bool
}
func (s *atlasStream) Recv() (*ai.Response, error) {
for s.scanner.Scan() {
line := strings.TrimSpace(s.scanner.Text())
if line == "" || strings.HasPrefix(line, ":") {
continue
}
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "[DONE]" {
return nil, io.EOF
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
}
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
return &ai.Response{Reply: chunk.Choices[0].Delta.Content}, nil
}
// Final chunk (after include_usage) carries token usage and no content.
if chunk.Usage != nil {
return &ai.Response{Usage: ai.Usage{
InputTokens: chunk.Usage.PromptTokens,
OutputTokens: chunk.Usage.CompletionTokens,
TotalTokens: chunk.Usage.TotalTokens,
}}, nil
}
continue
}
if err := s.scanner.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}
func (s *atlasStream) Close() error {
if s.closed {
return nil
}
s.closed = true
return s.body.Close()
}
func (p *Provider) callAPI(ctx context.Context, phase string, req map[string]any) (*ai.Response, map[string]any, error) {
reqBody, err := json.Marshal(req)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
@@ -169,20 +331,14 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
return nil, nil, fmt.Errorf("API error (%s) during atlascloud %s request (%s): %s", httpResp.Status, phase, atlascloudRequestSummary(req), string(respBody))
}
var chatResp struct {
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"`
Content string `json:"content"`
ToolCalls []atlasToolCall `json:"tool_calls"`
} `json:"message"`
} `json:"choices"`
}
@@ -214,12 +370,121 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
rawMessage := map[string]any{
"content": choice.Message.Content,
"tool_calls": choice.Message.ToolCalls,
"tool_calls": normalizeAtlasCloudToolCalls(choice.Message.ToolCalls),
}
return response, rawMessage, nil
}
func atlascloudTools(input []ai.Tool) []map[string]any {
tools := make([]map[string]any, 0, len(input))
for _, t := range input {
tools = append(tools, map[string]any{
"type": "function",
"function": map[string]any{
"name": t.Name,
"description": t.Description,
"parameters": map[string]any{
"type": "object",
"properties": normalizeAtlasCloudSchema(t.Properties),
},
},
})
}
return tools
}
func normalizeAtlasCloudSchema(schema map[string]any) map[string]any {
if schema == nil {
return nil
}
out := make(map[string]any, len(schema))
for k, v := range schema {
out[k] = normalizeAtlasCloudSchemaValue(v)
}
return out
}
func normalizeAtlasCloudSchemaValue(v any) any {
switch val := v.(type) {
case map[string]any:
out := make(map[string]any, len(val)+1)
for k, nested := range val {
out[k] = normalizeAtlasCloudSchemaValue(nested)
}
if typ, _ := out["type"].(string); typ == "array" {
if _, ok := out["items"]; !ok {
out["items"] = map[string]any{}
}
}
return out
case []any:
out := make([]any, len(val))
for i, nested := range val {
out[i] = normalizeAtlasCloudSchemaValue(nested)
}
return out
default:
return v
}
}
func normalizeAtlasCloudToolCalls(toolCalls []atlasToolCall) []map[string]any {
out := make([]map[string]any, 0, len(toolCalls))
for _, tc := range toolCalls {
toolType := tc.Type
if toolType == "" {
toolType = "function"
}
out = append(out, map[string]any{
"id": tc.ID,
"type": toolType,
"function": map[string]any{
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
},
})
}
return out
}
func atlascloudRequestSummary(req map[string]any) string {
parts := []string{}
if model, ok := req["model"].(string); ok && model != "" {
parts = append(parts, "model="+model)
}
if messages, ok := req["messages"].([]map[string]any); ok {
parts = append(parts, fmt.Sprintf("messages=%d", len(messages)))
if len(messages) > 0 {
last := messages[len(messages)-1]
if role, ok := last["role"].(string); ok && role != "" {
parts = append(parts, "last_role="+role)
}
if _, ok := last["tool_call_id"].(string); ok {
parts = append(parts, "last_has_tool_call_id=true")
}
}
}
if tools, ok := req["tools"].([]map[string]any); ok {
names := make([]string, 0, len(tools))
for _, tool := range tools {
fn, _ := tool["function"].(map[string]any)
name, _ := fn["name"].(string)
if name != "" {
names = append(names, name)
}
}
parts = append(parts, fmt.Sprintf("tools=%d", len(tools)))
if len(names) > 0 {
parts = append(parts, "tool_names="+strings.Join(names, ","))
}
}
if len(parts) == 0 {
return "request_context=unavailable"
}
return strings.Join(parts, " ")
}
const defaultImageModel = "openai/gpt-image-2/text-to-image"
// GenerateImage creates an image using Atlas Cloud's async image API.
+400 -6
View File
@@ -2,6 +2,12 @@ package atlascloud
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go-micro.dev/v6/ai"
@@ -80,16 +86,404 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
}
}
func TestProvider_Stream_NotImplemented(t *testing.T) {
p := NewProvider()
func TestProvider_Stream(t *testing.T) {
var sawStream, sawIncludeUsage bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Errorf("path = %s, want /v1/chat/completions", r.URL.Path)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
sawStream, _ = body["stream"].(bool)
if so, ok := body["stream_options"].(map[string]any); ok {
sawIncludeUsage, _ = so["include_usage"].(bool)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[],\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":2,\"total_tokens\":9}}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer ts.Close()
req := &ai.Request{
Prompt: "Hello",
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
if !sawStream {
t.Fatal("stream request did not set stream=true")
}
if !sawIncludeUsage {
t.Fatal("stream request did not set stream_options.include_usage=true")
}
_, err := p.Stream(context.Background(), req)
first, err := stream.Recv()
if err != nil || first.Reply != "hel" {
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
}
second, err := stream.Recv()
if err != nil || second.Reply != "lo" {
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
}
usage, err := stream.Recv()
if err != nil {
t.Fatalf("usage chunk error: %v", err)
}
if usage.Usage.TotalTokens != 9 || usage.Usage.InputTokens != 7 || usage.Usage.OutputTokens != 2 {
t.Fatalf("usage = %#v; want input=7 output=2 total=9", usage.Usage)
}
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
t.Fatalf("final error = %v, want EOF", err)
}
}
func TestProvider_StreamWithToolsFallsBack(t *testing.T) {
p := NewProvider(ai.WithAPIKey("test-key"))
_, err := p.Stream(context.Background(), &ai.Request{
Prompt: "call a tool",
Tools: []ai.Tool{{
Name: "fallback_echo",
Description: "echo fallback marker",
Properties: map[string]any{"value": map[string]any{"type": "string"}},
}},
})
if !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream with tools error = %v, want ErrStreamingUnsupported", err)
}
}
func TestProvider_GenerateToolCallEmptyFollowUpUsesToolResult(t *testing.T) {
var calls int
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Errorf("path = %s, want /v1/chat/completions", r.URL.Path)
}
calls++
w.Header().Set("Content-Type", "application/json")
switch calls {
case 1:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-1","function":{"name":"conformance_echo","arguments":"{\"value\":\"agent-conformance\"}"}}]}}]}`))
case 2:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":""}}]}`))
default:
t.Fatalf("unexpected API call %d", calls)
}
}))
defer ts.Close()
p := NewProvider(
ai.WithAPIKey("test-key"),
ai.WithBaseURL(ts.URL),
ai.WithToolHandler(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if call.Name != "conformance_echo" {
t.Fatalf("tool name = %q, want conformance_echo", call.Name)
}
return ai.ToolResult{ID: call.ID, Content: `{"marker":"agent-conformance-ok"}`}
}),
)
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "call a tool",
Tools: []ai.Tool{{
Name: "conformance_echo",
Description: "echo conformance marker",
Properties: map[string]any{"value": map[string]any{"type": "string"}},
}},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if calls != 2 {
t.Fatalf("API calls = %d, want 2", calls)
}
if resp.Answer != `{"marker":"agent-conformance-ok"}` {
t.Fatalf("Answer = %q, want tool result fallback", resp.Answer)
}
}
func TestProvider_GenerateMinimaxToolRequests(t *testing.T) {
var bodies []map[string]any
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
bodies = append(bodies, body)
w.Header().Set("Content-Type", "application/json")
switch len(bodies) {
case 1:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-1","function":{"name":"conformance_echo","arguments":"{\"value\":\"agent-conformance\"}"}}]}}]}`))
case 2:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"done"}}]}`))
default:
t.Fatalf("unexpected API call %d", len(bodies))
}
}))
defer ts.Close()
p := NewProvider(
ai.WithAPIKey("test-key"),
ai.WithBaseURL(ts.URL),
ai.WithModel("minimaxai/minimax-m3"),
ai.WithToolHandler(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
return ai.ToolResult{ID: call.ID, Content: `{"marker":"agent-conformance-ok"}`}
}),
)
resp, err := p.Generate(context.Background(), &ai.Request{
SystemPrompt: "You are helpful.",
Prompt: "call a tool",
Tools: []ai.Tool{{
Name: "conformance_echo",
Description: "echo conformance marker",
Properties: map[string]any{"value": map[string]any{"type": "string"}},
}},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if resp.Answer != "done" {
t.Fatalf("Answer = %q, want done", resp.Answer)
}
if len(bodies) != 2 {
t.Fatalf("captured requests = %d, want 2", len(bodies))
}
if got := bodies[0]["model"]; got != "minimaxai/minimax-m3" {
t.Fatalf("initial model = %v", got)
}
tools, ok := bodies[0]["tools"].([]any)
if !ok || len(tools) != 1 {
t.Fatalf("initial tools = %#v, want one tool", bodies[0]["tools"])
}
tool := tools[0].(map[string]any)
if tool["type"] != "function" {
t.Fatalf("tool type = %v, want function", tool["type"])
}
fn := tool["function"].(map[string]any)
if fn["name"] != "conformance_echo" {
t.Fatalf("tool function name = %v", fn["name"])
}
params := fn["parameters"].(map[string]any)
if params["type"] != "object" {
t.Fatalf("parameters type = %v, want object", params["type"])
}
followUpMessages := bodies[1]["messages"].([]any)
if len(followUpMessages) != 4 {
t.Fatalf("follow-up messages = %d, want 4", len(followUpMessages))
}
assistant := followUpMessages[2].(map[string]any)
if assistant["role"] != "assistant" {
t.Fatalf("assistant role = %v", assistant["role"])
}
assistantCalls := assistant["tool_calls"].([]any)
assistantCall := assistantCalls[0].(map[string]any)
if assistantCall["type"] != "function" {
t.Fatalf("assistant tool call type = %v, want function", assistantCall["type"])
}
toolResult := followUpMessages[3].(map[string]any)
if toolResult["role"] != "tool" || toolResult["tool_call_id"] != "call-1" {
t.Fatalf("tool result message = %#v", toolResult)
}
}
func TestProvider_GenerateNormalizesBuiltInToolSchemas(t *testing.T) {
var body map[string]any
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
}))
defer ts.Close()
planProperties := map[string]any{
"steps": map[string]any{
"type": "array",
"description": "ordered plan steps",
},
}
p := NewProvider(
ai.WithAPIKey("test-key"),
ai.WithBaseURL(ts.URL),
ai.WithModel("minimaxai/minimax-m3"),
)
_, err := p.Generate(context.Background(), &ai.Request{
Prompt: "plan and delegate",
Tools: []ai.Tool{
{Name: "task_TaskService_Add", Description: "add task", Properties: map[string]any{"title": map[string]any{"type": "string"}}},
{Name: "plan", Description: "record a plan", Properties: planProperties},
{Name: "request_input", Description: "request input", Properties: map[string]any{"prompt": map[string]any{"type": "string"}}},
{Name: "delegate", Description: "delegate work", Properties: map[string]any{"task": map[string]any{"type": "string"}, "to": map[string]any{"type": "string"}}},
},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
tools := body["tools"].([]any)
if len(tools) != 4 {
t.Fatalf("tools = %d, want custom tool plus built-ins", len(tools))
}
planTool := tools[1].(map[string]any)
fn := planTool["function"].(map[string]any)
params := fn["parameters"].(map[string]any)
props := params["properties"].(map[string]any)
steps := props["steps"].(map[string]any)
if _, ok := steps["items"].(map[string]any); !ok {
t.Fatalf("plan steps schema = %#v, want array items for AtlasCloud/minimax", steps)
}
if _, mutated := planProperties["steps"].(map[string]any)["items"]; mutated {
t.Fatalf("Generate mutated caller tool schema: %#v", planProperties)
}
}
func TestProvider_GenerateExecutesFollowUpToolCall(t *testing.T) {
var bodies []map[string]any
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
bodies = append(bodies, body)
w.Header().Set("Content-Type", "application/json")
switch len(bodies) {
case 1:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-1","function":{"name":"conformance_echo","arguments":"{\"value\":\"agent-conformance\"}"}}]}}]}`))
case 2:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-2","function":{"name":"delegate","arguments":"{\"task\":\"summarize the conformance marker\",\"to\":\"blocked-reviewer\"}"}}]}}]}`))
default:
t.Fatalf("unexpected API call %d", len(bodies))
}
}))
defer ts.Close()
var sawEcho, sawDelegate bool
p := NewProvider(
ai.WithAPIKey("test-key"),
ai.WithBaseURL(ts.URL),
ai.WithToolHandler(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
switch call.Name {
case "conformance_echo":
sawEcho = true
return ai.ToolResult{ID: call.ID, Content: `{"marker":"agent-conformance-ok"}`}
case "delegate":
sawDelegate = true
return ai.ToolResult{ID: call.ID, Refused: ai.RefusedApproval, Content: "blocked by policy"}
default:
t.Fatalf("unexpected tool call %+v", call)
return ai.ToolResult{}
}
}),
)
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "run conformance",
Tools: []ai.Tool{
{Name: "conformance_echo", Description: "echo conformance marker", Properties: map[string]any{"value": map[string]any{"type": "string"}}},
{Name: "delegate", Description: "delegate work", Properties: map[string]any{"task": map[string]any{"type": "string"}, "to": map[string]any{"type": "string"}}},
},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if !sawEcho || !sawDelegate {
t.Fatalf("sawEcho=%v sawDelegate=%v, want both tools executed", sawEcho, sawDelegate)
}
if len(resp.ToolCalls) != 2 {
t.Fatalf("ToolCalls = %+v, want echo and delegate", resp.ToolCalls)
}
if resp.ToolCalls[1].Name != "delegate" || resp.ToolCalls[1].Error != ai.RefusedApproval {
t.Fatalf("follow-up delegate = %+v, want refused delegate", resp.ToolCalls[1])
}
if !strings.Contains(resp.Answer, "blocked by policy") {
t.Fatalf("Answer = %q, want follow-up tool result", resp.Answer)
}
if _, ok := bodies[1]["tools"].([]any); !ok {
t.Fatalf("follow-up request did not include tools: %#v", bodies[1])
}
}
func TestProvider_GeneratePreservesFollowUpTextToolCallInReply(t *testing.T) {
var bodies []map[string]any
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
bodies = append(bodies, body)
w.Header().Set("Content-Type", "application/json")
switch len(bodies) {
case 1:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-1","function":{"name":"conformance_echo","arguments":"{\"value\":\"agent-conformance\"}"}}]}}]}`))
case 2:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"<tool_call name=\"delegate\">{\"task\":\"summarize the conformance marker\",\"to\":\"blocked-reviewer\"}</tool_call>"}}]}`))
default:
t.Fatalf("unexpected API call %d", len(bodies))
}
}))
defer ts.Close()
p := NewProvider(
ai.WithAPIKey("test-key"),
ai.WithBaseURL(ts.URL),
ai.WithToolHandler(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if call.Name != "conformance_echo" {
t.Fatalf("unexpected structured tool call %+v", call)
}
return ai.ToolResult{ID: call.ID, Content: `{"marker":"agent-conformance-ok"}`}
}),
)
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "run conformance",
Tools: []ai.Tool{
{Name: "conformance_echo", Description: "echo conformance marker", Properties: map[string]any{"value": map[string]any{"type": "string"}}},
{Name: "delegate", Description: "delegate work", Properties: map[string]any{"task": map[string]any{"type": "string"}, "to": map[string]any{"type": "string"}}},
},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if !strings.Contains(resp.Reply, `<tool_call name="delegate">`) {
t.Fatalf("Reply = %q, want tagged delegate follow-up for agent text fallback", resp.Reply)
}
if resp.Answer != "" {
t.Fatalf("Answer = %q, want follow-up text preserved only as Reply", resp.Answer)
}
}
func TestProvider_GenerateToolCallHTTPErrorIncludesRequestContext(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"code":400,"msg":"bad request"}`, http.StatusBadRequest)
}))
defer ts.Close()
p := NewProvider(
ai.WithAPIKey("test-key"),
ai.WithBaseURL(ts.URL),
ai.WithModel("minimaxai/minimax-m3"),
)
_, err := p.Generate(context.Background(), &ai.Request{
Prompt: "call a tool",
Tools: []ai.Tool{{
Name: "conformance_echo",
Description: "echo conformance marker",
Properties: map[string]any{"value": map[string]any{"type": "string"}},
}},
})
if err == nil {
t.Error("Expected error for unimplemented streaming, got nil")
t.Fatal("Generate error = nil, want 400")
}
msg := err.Error()
for _, want := range []string{"400 Bad Request", "atlascloud chat request", "model=minimaxai/minimax-m3", "tools=1", "tool_names=conformance_echo"} {
if !strings.Contains(msg, want) {
t.Fatalf("error %q missing %q", msg, want)
}
}
if strings.Contains(msg, "test-key") {
t.Fatalf("error leaked API key: %s", msg)
}
}
+140
View File
@@ -0,0 +1,140 @@
package ai
import "sort"
// CapabilityRow is one deterministic row in a provider capability matrix.
type CapabilityRow struct {
// Provider is the registered provider name.
Provider string `json:"provider"`
Capabilities
}
// Capabilities describes the AI interfaces a provider has registered.
// It is intentionally based on package registration rather than external
// provider marketing claims, so it reflects what this build can actually use.
type Capabilities struct {
// Model reports whether ai.New can construct a chat/text model provider.
Model bool `json:"model"`
// Image reports whether ai.NewImage can construct an image model provider.
Image bool `json:"image"`
// Video reports whether ai.NewVideo can construct a video model provider.
Video bool `json:"video"`
// Stream reports whether the provider has registered end-to-end token streaming.
// Providers that only satisfy the Model interface with ErrStreamingUnsupported
// leave this false until their Stream implementation is usable.
Stream bool `json:"stream"`
}
// ProviderCapabilities reports the capabilities registered for provider.
func ProviderCapabilities(provider string) Capabilities {
_, hasModel := providers[provider]
_, hasImage := imageProviders[provider]
_, hasVideo := videoProviders[provider]
_, hasStream := streamProviders[provider]
return Capabilities{
Model: hasModel,
Image: hasImage,
Video: hasVideo,
Stream: hasStream,
}
}
// CapabilityMatrix returns a snapshot of all registered AI providers and the
// interfaces they support. The returned map is a copy and can be modified by
// callers without mutating the registry. Use CapabilityRows when rendering a
// deterministic table or report.
func CapabilityMatrix() map[string]Capabilities {
names := map[string]struct{}{}
for name := range providers {
names[name] = struct{}{}
}
for name := range imageProviders {
names[name] = struct{}{}
}
for name := range videoProviders {
names[name] = struct{}{}
}
for name := range streamProviders {
names[name] = struct{}{}
}
matrix := make(map[string]Capabilities, len(names))
for name := range names {
matrix[name] = ProviderCapabilities(name)
}
return matrix
}
// CapabilityRows returns a deterministic capability support matrix for every
// registered AI provider. It is the ordered form of CapabilityMatrix, intended
// for CLIs, docs generators, and conformance reports that need stable output.
func CapabilityRows() []CapabilityRow {
names := RegisteredProviders("")
rows := make([]CapabilityRow, 0, len(names))
for _, name := range names {
rows = append(rows, CapabilityRow{
Provider: name,
Capabilities: ProviderCapabilities(name),
})
}
return rows
}
// RegisterStream records that provider has a usable Stream implementation.
// Providers should call this from init alongside Register once Stream returns
// chunks instead of ErrStreamingUnsupported.
func RegisterStream(provider string) {
streamProviders[provider] = struct{}{}
}
var streamProviders = make(map[string]struct{})
// RegisteredProviders returns the registered provider names in sorted order.
// kind may be "model", "image", "video", "stream", or empty for the union of all
// provider registries.
func RegisteredProviders(kind string) []string {
names := map[string]struct{}{}
add := func(registry any) {
switch r := registry.(type) {
case map[string]NewFunc:
for name := range r {
names[name] = struct{}{}
}
case map[string]NewImageFunc:
for name := range r {
names[name] = struct{}{}
}
case map[string]NewVideoFunc:
for name := range r {
names[name] = struct{}{}
}
case map[string]struct{}:
for name := range r {
names[name] = struct{}{}
}
}
}
switch kind {
case "model":
add(providers)
case "stream":
add(streamProviders)
case "image":
add(imageProviders)
case "video":
add(videoProviders)
default:
add(providers)
add(imageProviders)
add(videoProviders)
}
out := make([]string, 0, len(names))
for name := range names {
out = append(out, name)
}
sort.Strings(out)
return out
}
+97
View File
@@ -0,0 +1,97 @@
package ai_test
import (
"reflect"
"testing"
"go-micro.dev/v6/ai"
_ "go-micro.dev/v6/ai/anthropic"
_ "go-micro.dev/v6/ai/atlascloud"
_ "go-micro.dev/v6/ai/gemini"
_ "go-micro.dev/v6/ai/groq"
_ "go-micro.dev/v6/ai/minimax"
_ "go-micro.dev/v6/ai/mistral"
_ "go-micro.dev/v6/ai/openai"
_ "go-micro.dev/v6/ai/together"
)
func TestRegisteredProviders(t *testing.T) {
got := ai.RegisteredProviders("")
want := []string{"anthropic", "atlascloud", "gemini", "groq", "minimax", "mistral", "openai", "together"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders() = %#v, want %#v", got, want)
}
got = ai.RegisteredProviders("image")
want = []string{"atlascloud", "openai"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders(image) = %#v, want %#v", got, want)
}
got = ai.RegisteredProviders("video")
want = []string{"atlascloud"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders(video) = %#v, want %#v", got, want)
}
got = ai.RegisteredProviders("stream")
want = []string{"anthropic", "atlascloud", "groq", "minimax", "mistral", "openai", "together"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
}
}
func TestCapabilityRows(t *testing.T) {
got := ai.CapabilityRows()
want := []ai.CapabilityRow{
{Provider: "anthropic", Capabilities: ai.Capabilities{Model: true, Stream: true}},
{Provider: "atlascloud", Capabilities: ai.Capabilities{Model: true, Image: true, Video: true, Stream: true}},
{Provider: "gemini", Capabilities: ai.Capabilities{Model: true}},
{Provider: "groq", Capabilities: ai.Capabilities{Model: true, Stream: true}},
{Provider: "minimax", Capabilities: ai.Capabilities{Model: true, Stream: true}},
{Provider: "mistral", Capabilities: ai.Capabilities{Model: true, Stream: true}},
{Provider: "openai", Capabilities: ai.Capabilities{Model: true, Image: true, Stream: true}},
{Provider: "together", Capabilities: ai.Capabilities{Model: true, Stream: true}},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("CapabilityRows() = %#v, want %#v", got, want)
}
}
func TestCapabilityMatrix(t *testing.T) {
matrix := ai.CapabilityMatrix()
for _, provider := range []string{"anthropic", "atlascloud", "gemini", "groq", "minimax", "mistral", "openai", "together"} {
caps, ok := matrix[provider]
if !ok {
t.Fatalf("CapabilityMatrix missing %q", provider)
}
if !caps.Model {
t.Fatalf("CapabilityMatrix(%s).Model = false, want true", provider)
}
}
if caps := ai.ProviderCapabilities("openai"); caps != (ai.Capabilities{Model: true, Image: true, Stream: true}) {
t.Fatalf("ProviderCapabilities(openai) = %#v", caps)
}
if caps := ai.ProviderCapabilities("atlascloud"); caps != (ai.Capabilities{Model: true, Image: true, Video: true, Stream: true}) {
t.Fatalf("ProviderCapabilities(atlascloud) = %#v", caps)
}
if caps := ai.ProviderCapabilities("missing"); caps != (ai.Capabilities{}) {
t.Fatalf("ProviderCapabilities(missing) = %#v", caps)
}
}
func TestRegisterStream(t *testing.T) {
ai.RegisterStream("test-stream")
if caps := ai.ProviderCapabilities("test-stream"); caps != (ai.Capabilities{Stream: true}) {
t.Fatalf("ProviderCapabilities(test-stream) = %#v", caps)
}
got := ai.RegisteredProviders("stream")
want := []string{"anthropic", "atlascloud", "groq", "minimax", "mistral", "openai", "test-stream", "together"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
}
}
+1 -1
View File
@@ -135,7 +135,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
}
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("streaming not yet implemented for gemini provider")
return nil, fmt.Errorf("%w: gemini provider", ai.ErrStreamingUnsupported)
}
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, []map[string]any, error) {
+3 -2
View File
@@ -2,6 +2,7 @@ package gemini
import (
"context"
"errors"
"testing"
"go-micro.dev/v6/ai"
@@ -88,8 +89,8 @@ func TestProvider_Stream_NotImplemented(t *testing.T) {
}
_, err := p.Stream(context.Background(), req)
if err == nil {
t.Error("Expected error for unimplemented streaming, got nil")
if !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
}
}
+3 -1
View File
@@ -22,12 +22,14 @@ import (
"strings"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/ai/internal/openaiapi"
)
func init() {
ai.Register("groq", func(opts ...ai.Option) ai.Model {
return NewProvider(opts...)
})
ai.RegisterStream("groq")
}
type Provider struct {
@@ -119,7 +121,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
}
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("streaming not yet implemented for groq provider")
return openaiapi.Stream(ctx, p.opts, req, "/v1/chat/completions")
}
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
+43 -3
View File
@@ -2,6 +2,11 @@ package groq
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"go-micro.dev/v6/ai"
@@ -39,9 +44,44 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
}
}
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_Stream(t *testing.T) {
var sawStream bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Fatalf("path = %s, want /v1/chat/completions", r.URL.Path)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
sawStream, _ = body["stream"].(bool)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer ts.Close()
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
if !sawStream {
t.Fatal("stream request did not set stream=true")
}
first, err := stream.Recv()
if err != nil || first.Reply != "hel" {
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
}
second, err := stream.Recv()
if err != nil || second.Reply != "lo" {
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
}
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
t.Fatalf("final error = %v, want EOF", err)
}
}
+117
View File
@@ -0,0 +1,117 @@
package openaiapi
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"go-micro.dev/v6/ai"
)
// Stream opens an OpenAI-compatible chat completions SSE stream.
func Stream(ctx context.Context, opts ai.Options, req *ai.Request, basePath string) (ai.Stream, error) {
messages := []map[string]any{{"role": "system", "content": req.SystemPrompt}}
for _, m := range req.Messages {
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
}
apiReq := map[string]any{
"model": opts.Model,
"messages": messages,
"stream": true,
"stream_options": map[string]any{"include_usage": true},
}
if opts.MaxTokens > 0 {
apiReq["max_tokens"] = opts.MaxTokens
}
reqBody, err := json.Marshal(apiReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
}
apiURL := strings.TrimRight(opts.BaseURL, "/") + basePath
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create stream request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "text/event-stream")
httpReq.Header.Set("Authorization", "Bearer "+opts.APIKey)
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("stream API request failed: %w", err)
}
if httpResp.StatusCode != http.StatusOK {
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
return nil, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(respBody))
}
return &StreamReader{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
}
// StreamReader reads OpenAI-compatible server-sent event chunks.
type StreamReader struct {
body io.ReadCloser
scanner *bufio.Scanner
closed bool
}
func (s *StreamReader) Recv() (*ai.Response, error) {
for s.scanner.Scan() {
line := strings.TrimSpace(s.scanner.Text())
if line == "" || strings.HasPrefix(line, ":") {
continue
}
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "[DONE]" {
return nil, io.EOF
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
}
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
return &ai.Response{Reply: chunk.Choices[0].Delta.Content}, nil
}
if chunk.Usage != nil {
return &ai.Response{Usage: ai.Usage{
InputTokens: chunk.Usage.PromptTokens,
OutputTokens: chunk.Usage.CompletionTokens,
TotalTokens: chunk.Usage.TotalTokens,
}}, nil
}
}
if err := s.scanner.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}
func (s *StreamReader) Close() error {
if s.closed {
return nil
}
s.closed = true
return s.body.Close()
}
+196
View File
@@ -0,0 +1,196 @@
// Package minimax implements the MiniMax model provider.
//
// MiniMax offers its flagship MiniMax-M3 model via an OpenAI-compatible
// chat completions endpoint.
//
// Usage:
//
// import _ "go-micro.dev/v6/ai/minimax"
//
// m := ai.New("minimax",
// ai.WithAPIKey("your-api-key"),
// )
package minimax
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/ai/internal/openaiapi"
)
func init() {
ai.Register("minimax", func(opts ...ai.Option) ai.Model {
return NewProvider(opts...)
})
ai.RegisterStream("minimax")
}
type Provider struct {
opts ai.Options
}
func NewProvider(opts ...ai.Option) *Provider {
options := ai.NewOptions(opts...)
if options.Model == "" {
options.Model = "MiniMax-M3"
}
if options.BaseURL == "" {
options.BaseURL = "https://api.minimax.io"
}
return &Provider{opts: options}
}
func (p *Provider) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&p.opts)
}
return nil
}
func (p *Provider) Options() ai.Options { return p.opts }
func (p *Provider) String() string { return "minimax" }
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
var tools []map[string]any
for _, t := range req.Tools {
tools = append(tools, map[string]any{
"type": "function",
"function": map[string]any{
"name": t.Name,
"description": t.Description,
"parameters": map[string]any{
"type": "object",
"properties": t.Properties,
},
},
})
}
messages := []map[string]any{
{"role": "system", "content": req.SystemPrompt},
{"role": "user", "content": req.Prompt},
}
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
}
if len(tools) > 0 {
apiReq["tools"] = tools
}
resp, rawMessage, err := p.callAPI(ctx, apiReq)
if err != nil {
return nil, err
}
if len(resp.ToolCalls) == 0 {
return resp, nil
}
if p.opts.ToolHandler != nil {
followUpMessages := append(messages, map[string]any{
"role": "assistant",
"content": rawMessage["content"],
"tool_calls": rawMessage["tool_calls"],
})
for _, tc := range resp.ToolCalls {
content := p.opts.ToolHandler(ctx, tc).Content
followUpMessages = append(followUpMessages, map[string]any{
"role": "tool",
"tool_call_id": tc.ID,
"content": content,
})
}
followUpResp, _, err := p.callAPI(ctx, map[string]any{
"model": p.opts.Model,
"messages": followUpMessages,
})
if err == nil && followUpResp.Reply != "" {
resp.Answer = followUpResp.Reply
}
}
return resp, nil
}
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return openaiapi.Stream(ctx, p.opts, req, "/v1/chat/completions")
}
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
reqBody, err := json.Marshal(req)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, nil, fmt.Errorf("API request failed: %w", err)
}
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var chatResp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
}
if len(chatResp.Choices) == 0 {
return nil, nil, fmt.Errorf("no response from API")
}
choice := chatResp.Choices[0]
response := &ai.Response{Reply: choice.Message.Content}
for _, tc := range choice.Message.ToolCalls {
var input map[string]any
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
input = map[string]any{}
}
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Input: input,
})
}
rawMessage := map[string]any{
"content": choice.Message.Content,
"tool_calls": choice.Message.ToolCalls,
}
return response, rawMessage, nil
}
+96
View File
@@ -0,0 +1,96 @@
package minimax
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"go-micro.dev/v6/ai"
)
func TestProvider_String(t *testing.T) {
if NewProvider().String() != "minimax" {
t.Errorf("got %q", NewProvider().String())
}
}
func TestProvider_Defaults(t *testing.T) {
opts := NewProvider().Options()
if opts.Model != "MiniMax-M3" {
t.Errorf("default model = %q", opts.Model)
}
if opts.BaseURL != "https://api.minimax.io" {
t.Errorf("default base URL = %q", opts.BaseURL)
}
}
func TestProvider_Init(t *testing.T) {
p := NewProvider()
if err := p.Init(ai.WithModel("m"), ai.WithAPIKey("k")); err != nil {
t.Fatal(err)
}
if p.Options().Model != "m" || p.Options().APIKey != "k" {
t.Error("Init did not apply options")
}
}
func TestProvider_Generate_NoAPIKey(t *testing.T) {
if _, err := NewProvider().Generate(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
t.Error("expected error without API key")
}
}
func TestProvider_Stream(t *testing.T) {
var sawStream bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Fatalf("path = %s, want /v1/chat/completions", r.URL.Path)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
sawStream, _ = body["stream"].(bool)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer ts.Close()
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
if !sawStream {
t.Fatal("stream request did not set stream=true")
}
first, err := stream.Recv()
if err != nil || first.Reply != "hel" {
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
}
second, err := stream.Recv()
if err != nil || second.Reply != "lo" {
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
}
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
t.Fatalf("final error = %v, want EOF", err)
}
}
func TestProvider_Registration(t *testing.T) {
m := ai.New("minimax", ai.WithAPIKey("test"))
if m == nil {
t.Fatal("provider not registered")
}
if m.String() != "minimax" {
t.Errorf("got %q", m.String())
}
}
+3 -1
View File
@@ -22,12 +22,14 @@ import (
"strings"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/ai/internal/openaiapi"
)
func init() {
ai.Register("mistral", func(opts ...ai.Option) ai.Model {
return NewProvider(opts...)
})
ai.RegisterStream("mistral")
}
type Provider struct {
@@ -119,7 +121,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
}
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("streaming not yet implemented for mistral provider")
return openaiapi.Stream(ctx, p.opts, req, "/v1/chat/completions")
}
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
+43 -3
View File
@@ -2,6 +2,11 @@ package mistral
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"go-micro.dev/v6/ai"
@@ -39,9 +44,44 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
}
}
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_Stream(t *testing.T) {
var sawStream bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Fatalf("path = %s, want /v1/chat/completions", r.URL.Path)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
sawStream, _ = body["stream"].(bool)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer ts.Close()
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
if !sawStream {
t.Fatal("stream request did not set stream=true")
}
first, err := stream.Recv()
if err != nil || first.Reply != "hel" {
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
}
second, err := stream.Recv()
if err != nil || second.Reply != "lo" {
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
}
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
t.Fatalf("final error = %v, want EOF", err)
}
}
+30 -9
View File
@@ -4,6 +4,7 @@ package ai
import (
"context"
"encoding/json"
"errors"
"strings"
)
@@ -92,9 +93,10 @@ func (c ToolCall) Scan(v any) error {
// 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
ID string // Tool call ID (for correlation)
Value any // Structured result (optional)
Content string // Tool execution result (JSON string), shown to the model
Attempts int `json:"attempts,omitempty"` // Tool execution attempts, set when retried.
// 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
@@ -112,12 +114,24 @@ const (
// 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.
// the agent package. Flows also attach their name and current step so
// tools and agents called from a workflow can be tied back to the
// services → agents → workflows lifecycle that invoked them. Per-call
// detail (tool name, id) is on the ToolCall. Attempt and MaxAttempts are
// set while a model Generate call is in progress, so tools and wrappers can
// tell which provider attempt produced the call and whether it is part of a
// retry budget. They are zero when no model-attempt context is known.
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
RunID string // correlation id for this agent or flow run
ParentID string // the run that delegated to this one, if any
Agent string // the agent's name
Flow string // the flow's name, when the call is part of a workflow
Step string // the flow step currently executing, when known
Attempt int // current model Generate attempt, starting at 1 when known
MaxAttempts int // configured model Generate attempt budget when known
VerificationFeedback string // feedback from the previous failed verifier attempt, when retrying a flow step
Dispatch string // how the run was dispatched (direct, broker, schedule, resume) when known
Trigger string // external trigger or schedule label that started the run, when known
}
type runInfoKey struct{}
@@ -133,7 +147,12 @@ func RunInfoFrom(ctx context.Context) (RunInfo, bool) {
return r, ok
}
// Stream is the interface for streaming responses (future implementation)
// ErrStreamingUnsupported is returned by providers that implement the Model
// interface but do not yet support token streaming. Use errors.Is so callers
// can distinguish an unsupported capability from transient provider failures.
var ErrStreamingUnsupported = errors.New("ai: streaming unsupported")
// Stream is the interface for streaming responses.
type Stream interface {
// Recv receives the next chunk of the response
Recv() (*Response, error)
@@ -193,6 +212,8 @@ func AutoDetectProvider(baseURL string) string {
return "gemini"
case strings.Contains(baseURL, "groq"):
return "groq"
case strings.Contains(baseURL, "minimax"):
return "minimax"
case strings.Contains(baseURL, "mistral"):
return "mistral"
case strings.Contains(baseURL, "together"):
+729
View File
@@ -0,0 +1,729 @@
// Package ollama implements the Ollama model provider.
//
// Ollama runs open-weight models locally (or via Ollama Cloud). This
// provider supports two API styles:
//
// - Native (/api/chat): local Ollama servers (default, http://localhost:11434)
// - OpenAI-compatible (/v1/chat/completions): Ollama Cloud (https://ollama.com/v1)
//
// The provider auto-detects which style to use based on the base URL.
// Set OLLAMA_BASE_URL to point at your server (local or cloud).
//
// Usage (local):
//
// import _ "go-micro.dev/v6/ai/ollama"
//
// m := ai.New("ollama",
// ai.WithBaseURL("http://localhost:11434"),
// ai.WithModel("llama3.2"),
// )
//
// Usage (Ollama Cloud):
//
// m := ai.New("ollama",
// ai.WithBaseURL("https://ollama.com/v1"),
// ai.WithAPIKey("your-key"),
// ai.WithModel("gpt-oss:120b"),
// )
package ollama
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"go-micro.dev/v6/ai"
)
func init() {
ai.Register("ollama", func(opts ...ai.Option) ai.Model {
return NewProvider(opts...)
})
ai.RegisterStream("ollama")
}
// Provider implements the ai.Model interface for Ollama.
type Provider struct {
opts ai.Options
// cloudOverride forces cloud mode for testing. When true, the provider
// uses the OpenAI-compatible endpoint regardless of the base URL.
cloudOverride bool
}
// NewProvider creates a new Ollama provider.
func NewProvider(opts ...ai.Option) *Provider {
options := ai.NewOptions(opts...)
if options.Model == "" {
options.Model = "llama3.2"
}
if options.BaseURL == "" {
options.BaseURL = "http://localhost:11434"
}
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 "ollama" }
// isCloud returns true when the base URL points at Ollama Cloud (ollama.com),
// which uses the OpenAI-compatible /v1/chat/completions endpoint instead of
// the native /api/chat.
func (p *Provider) isCloud() bool {
if p.cloudOverride {
return true
}
return strings.Contains(p.opts.BaseURL, "ollama.com")
}
// chatPath returns the API endpoint path for chat completions.
func (p *Provider) chatPath() string {
if p.isCloud() {
return "/v1/chat/completions"
}
return "/api/chat"
}
// streamPath returns the API endpoint path for streaming chat.
// Ollama Cloud uses the same /v1/chat/completions with stream:true.
// Local Ollama uses /api/chat with stream:true.
func (p *Provider) streamPath() string {
return p.chatPath()
}
// Generate generates a response from the Ollama model.
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
if p.isCloud() {
return p.generateOpenAI(ctx, req)
}
return p.generateNative(ctx, req)
}
// Stream generates a streaming response.
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
if p.isCloud() {
return p.streamOpenAI(ctx, req)
}
return p.streamNative(ctx, req)
}
// ---------------------------------------------------------------------------
// OpenAI-compatible mode (Ollama Cloud: ollama.com/v1)
// ---------------------------------------------------------------------------
func (p *Provider) generateOpenAI(ctx context.Context, req *ai.Request) (*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 := buildOpenAIMessages(req)
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
"stream": false,
}
if len(tools) > 0 {
apiReq["tools"] = tools
}
if p.opts.MaxTokens > 0 {
apiReq["max_tokens"] = p.opts.MaxTokens
}
resp, rawMsg, err := p.callOpenAI(ctx, apiReq)
if err != nil {
return nil, err
}
// No tool calls or no handler — return as-is.
if len(resp.ToolCalls) == 0 || p.opts.ToolHandler == nil {
return resp, nil
}
// Tool execution loop.
convMessages := append(messages, map[string]any{
"role": "assistant",
"content": rawMsg.content,
"tool_calls": rawMsg.toolCalls,
})
pendingCalls := resp.ToolCalls
for round := 0; round < 10; round++ {
for i := range pendingCalls {
result := p.opts.ToolHandler(ctx, pendingCalls[i])
pendingCalls[i].Result = result.Content
convMessages = append(convMessages, map[string]any{
"role": "tool",
"tool_call_id": pendingCalls[i].ID,
"content": result.Content,
})
}
followUpReq := map[string]any{
"model": p.opts.Model,
"messages": convMessages,
"stream": false,
}
if len(tools) > 0 {
followUpReq["tools"] = tools
}
if p.opts.MaxTokens > 0 {
followUpReq["max_tokens"] = p.opts.MaxTokens
}
followUpResp, followUpRaw, err := p.callOpenAI(ctx, followUpReq)
if err != nil {
break
}
if len(followUpResp.ToolCalls) > 0 {
resp.ToolCalls = append(resp.ToolCalls, followUpResp.ToolCalls...)
pendingCalls = followUpResp.ToolCalls
convMessages = append(convMessages, map[string]any{
"role": "assistant",
"content": followUpRaw.content,
"tool_calls": followUpRaw.toolCalls,
})
continue
}
if followUpResp.Reply != "" {
resp.Answer = followUpResp.Reply
}
break
}
return resp, nil
}
func (p *Provider) callOpenAI(ctx context.Context, req map[string]any) (*ai.Response, *rawChatMessage, 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, "/") + p.chatPath()
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")
if p.opts.APIKey != "" {
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 {
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
Choices []struct {
Message struct {
Role string `json:"role"`
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,
},
}
var rawToolCalls []map[string]any
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,
})
rawToolCalls = append(rawToolCalls, map[string]any{
"id": tc.ID,
"type": "function",
"function": map[string]any{
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
},
})
}
raw := &rawChatMessage{
content: choice.Message.Content,
toolCalls: rawToolCalls,
}
return response, raw, nil
}
func (p *Provider) streamOpenAI(ctx context.Context, req *ai.Request) (ai.Stream, error) {
messages := buildOpenAIMessages(req)
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
"stream": true,
"stream_options": map[string]any{"include_usage": true},
}
if p.opts.MaxTokens > 0 {
apiReq["max_tokens"] = p.opts.MaxTokens
}
reqBody, err := json.Marshal(apiReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + p.streamPath()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create stream request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "text/event-stream")
if p.opts.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
}
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("stream API request failed: %w", err)
}
if httpResp.StatusCode != http.StatusOK {
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
return nil, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(respBody))
}
return &sseStream{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
}
// buildOpenAIMessages converts an ai.Request into the OpenAI chat message format.
func buildOpenAIMessages(req *ai.Request) []map[string]any {
messages := []map[string]any{}
if req.SystemPrompt != "" {
messages = append(messages, map[string]any{"role": "system", "content": req.SystemPrompt})
}
for _, m := range req.Messages {
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
}
return messages
}
// sseStream reads OpenAI-style server-sent events (used by Ollama Cloud).
type sseStream struct {
body io.ReadCloser
scanner *bufio.Scanner
closed bool
}
func (s *sseStream) Recv() (*ai.Response, error) {
for s.scanner.Scan() {
line := strings.TrimSpace(s.scanner.Text())
if line == "" || strings.HasPrefix(line, ":") {
continue
}
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "[DONE]" {
return nil, io.EOF
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
}
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
return &ai.Response{Reply: chunk.Choices[0].Delta.Content}, nil
}
if chunk.Usage != nil {
return &ai.Response{Usage: ai.Usage{
InputTokens: chunk.Usage.PromptTokens,
OutputTokens: chunk.Usage.CompletionTokens,
TotalTokens: chunk.Usage.TotalTokens,
}}, nil
}
}
if err := s.scanner.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}
func (s *sseStream) Close() error {
if s.closed {
return nil
}
s.closed = true
return s.body.Close()
}
// ---------------------------------------------------------------------------
// Native mode (local Ollama: localhost:11434/api/chat)
// ---------------------------------------------------------------------------
func (p *Provider) generateNative(ctx context.Context, req *ai.Request) (*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{}
if req.SystemPrompt != "" {
messages = append(messages, map[string]any{"role": "system", "content": req.SystemPrompt})
}
for _, m := range req.Messages {
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
}
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
"stream": false,
}
if len(tools) > 0 {
apiReq["tools"] = tools
}
if p.opts.MaxTokens > 0 {
apiReq["options"] = map[string]any{"num_predict": p.opts.MaxTokens}
}
resp, rawMsg, err := p.callNative(ctx, apiReq)
if err != nil {
return nil, err
}
if len(resp.ToolCalls) == 0 || p.opts.ToolHandler == nil {
return resp, nil
}
convMessages := append(messages, map[string]any{
"role": "assistant",
"content": rawMsg.content,
})
if len(rawMsg.toolCalls) > 0 {
convMessages[len(convMessages)-1]["tool_calls"] = rawMsg.toolCalls
}
pendingCalls := resp.ToolCalls
for round := 0; round < 10; round++ {
for i := range pendingCalls {
result := p.opts.ToolHandler(ctx, pendingCalls[i])
pendingCalls[i].Result = result.Content
convMessages = append(convMessages, map[string]any{
"role": "tool",
"content": result.Content,
})
}
followUpReq := map[string]any{
"model": p.opts.Model,
"messages": convMessages,
"stream": false,
}
if len(tools) > 0 {
followUpReq["tools"] = tools
}
if p.opts.MaxTokens > 0 {
followUpReq["options"] = map[string]any{"num_predict": p.opts.MaxTokens}
}
followUpResp, followUpRaw, err := p.callNative(ctx, followUpReq)
if err != nil {
break
}
if len(followUpResp.ToolCalls) > 0 {
resp.ToolCalls = append(resp.ToolCalls, followUpResp.ToolCalls...)
pendingCalls = followUpResp.ToolCalls
convMessages = append(convMessages, map[string]any{
"role": "assistant",
"content": followUpRaw.content,
})
if len(followUpRaw.toolCalls) > 0 {
convMessages[len(convMessages)-1]["tool_calls"] = followUpRaw.toolCalls
}
continue
}
if followUpResp.Reply != "" {
resp.Answer = followUpResp.Reply
}
break
}
return resp, nil
}
func (p *Provider) callNative(ctx context.Context, req map[string]any) (*ai.Response, *rawChatMessage, 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, "/") + p.chatPath()
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")
if p.opts.APIKey != "" {
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 {
Message struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []struct {
Function struct {
Name string `json:"name"`
Arguments any `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
Done bool `json:"done"`
PromptEvalCount int `json:"prompt_eval_count"`
EvalCount int `json:"eval_count"`
}
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
}
response := &ai.Response{
Reply: chatResp.Message.Content,
Usage: ai.Usage{
InputTokens: chatResp.PromptEvalCount,
OutputTokens: chatResp.EvalCount,
TotalTokens: chatResp.PromptEvalCount + chatResp.EvalCount,
},
}
var rawToolCalls []map[string]any
for _, tc := range chatResp.Message.ToolCalls {
var input map[string]any
switch v := tc.Function.Arguments.(type) {
case string:
if err := json.Unmarshal([]byte(v), &input); err != nil {
input = map[string]any{}
}
case map[string]any:
input = v
default:
input = map[string]any{}
}
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
Name: tc.Function.Name,
Input: input,
})
rawToolCalls = append(rawToolCalls, map[string]any{
"function": map[string]any{
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
},
})
}
raw := &rawChatMessage{
content: chatResp.Message.Content,
toolCalls: rawToolCalls,
}
return response, raw, nil
}
func (p *Provider) streamNative(ctx context.Context, req *ai.Request) (ai.Stream, error) {
messages := []map[string]any{}
if req.SystemPrompt != "" {
messages = append(messages, map[string]any{"role": "system", "content": req.SystemPrompt})
}
for _, m := range req.Messages {
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
}
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
"stream": true,
}
if p.opts.MaxTokens > 0 {
apiReq["options"] = map[string]any{"num_predict": p.opts.MaxTokens}
}
reqBody, err := json.Marshal(apiReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + p.streamPath()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create stream request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
if p.opts.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
}
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("stream API request failed: %w", err)
}
if httpResp.StatusCode != http.StatusOK {
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
return nil, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(respBody))
}
return &ndjsonStream{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
}
// ndjsonStream reads newline-delimited JSON (used by local Ollama).
type ndjsonStream struct {
body io.ReadCloser
scanner *bufio.Scanner
closed bool
}
func (s *ndjsonStream) Recv() (*ai.Response, error) {
for s.scanner.Scan() {
line := strings.TrimSpace(s.scanner.Text())
if line == "" {
continue
}
var chunk struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
Done bool `json:"done"`
}
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
}
if chunk.Done {
return nil, io.EOF
}
if chunk.Message.Content != "" {
return &ai.Response{Reply: chunk.Message.Content}, nil
}
}
if err := s.scanner.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}
func (s *ndjsonStream) Close() error {
if s.closed {
return nil
}
s.closed = true
return s.body.Close()
}
// rawChatMessage holds the raw assistant content and tool calls for
// follow-up messages.
type rawChatMessage struct {
content string
toolCalls []map[string]any
}
+333
View File
@@ -0,0 +1,333 @@
package ollama
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go-micro.dev/v6/ai"
)
// ---------------------------------------------------------------------------
// Provider basics
// ---------------------------------------------------------------------------
func TestProvider_String(t *testing.T) {
p := NewProvider()
if p.String() != "ollama" {
t.Errorf("Expected 'ollama', 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_Defaults(t *testing.T) {
p := NewProvider()
opts := p.Options()
if opts.Model != "llama3.2" {
t.Errorf("Expected default model 'llama3.2', got '%s'", opts.Model)
}
if opts.BaseURL != "http://localhost:11434" {
t.Errorf("Expected default base URL 'http://localhost:11434', got '%s'", opts.BaseURL)
}
}
func TestProvider_IsCloud(t *testing.T) {
local := NewProvider(ai.WithBaseURL("http://localhost:11434"))
if local.isCloud() {
t.Error("localhost should not be cloud")
}
cloud := NewProvider(ai.WithBaseURL("https://ollama.com/v1"))
if !cloud.isCloud() {
t.Error("ollama.com should be cloud")
}
}
// ---------------------------------------------------------------------------
// Native mode (local Ollama: /api/chat)
// ---------------------------------------------------------------------------
func TestNative_Generate(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/chat" {
t.Errorf("Expected /api/chat, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"model": "llama3.2",
"message": {"role": "assistant", "content": "Hello from local Ollama!"},
"done": true,
"prompt_eval_count": 10,
"eval_count": 5
}`))
}))
defer srv.Close()
p := NewProvider(ai.WithBaseURL(srv.URL), ai.WithModel("llama3.2"))
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "Hi",
SystemPrompt: "You are helpful",
})
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
if resp.Reply != "Hello from local Ollama!" {
t.Errorf("Expected 'Hello from local Ollama!', got '%s'", resp.Reply)
}
if resp.Usage.TotalTokens != 15 {
t.Errorf("Expected total tokens 15, got %d", resp.Usage.TotalTokens)
}
}
func TestNative_GenerateWithToolCall(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
if callCount == 1 {
w.Write([]byte(`{
"model": "llama3.2",
"message": {
"role": "assistant",
"content": "",
"tool_calls": [{"function": {"name": "get_weather", "arguments": "{\"city\":\"Seoul\"}"}}]
},
"done": true
}`))
} else {
w.Write([]byte(`{
"model": "llama3.2",
"message": {"role": "assistant", "content": "The weather in Seoul is sunny."},
"done": true
}`))
}
}))
defer srv.Close()
handler := func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if call.Name != "get_weather" {
t.Errorf("Expected tool 'get_weather', got '%s'", call.Name)
}
return ai.ToolResult{ID: call.ID, Content: `{"temp": 22, "condition": "sunny"}`}
}
p := NewProvider(
ai.WithBaseURL(srv.URL),
ai.WithModel("llama3.2"),
ai.WithToolHandler(handler),
)
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "What's the weather?",
Tools: []ai.Tool{{
Name: "get_weather",
Description: "Get weather",
Properties: map[string]any{"city": map[string]any{"type": "string"}},
}},
})
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
if len(resp.ToolCalls) == 0 {
t.Error("Expected tool calls")
}
if resp.Answer != "The weather in Seoul is sunny." {
t.Errorf("Expected final answer, got '%s'", resp.Answer)
}
}
func TestNative_Stream(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"message":{"role":"assistant","content":"Hello"},"done":false}` + "\n"))
w.Write([]byte(`{"message":{"role":"assistant","content":" world"},"done":false}` + "\n"))
w.Write([]byte(`{"message":{"role":"assistant","content":""},"done":true}` + "\n"))
}))
defer srv.Close()
p := NewProvider(ai.WithBaseURL(srv.URL), ai.WithModel("llama3.2"))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hi"})
if err != nil {
t.Fatalf("Stream failed: %v", err)
}
defer stream.Close()
var chunks []string
for {
resp, err := stream.Recv()
if err != nil {
break
}
if resp.Reply != "" {
chunks = append(chunks, resp.Reply)
}
}
result := strings.Join(chunks, "")
if result != "Hello world" {
t.Errorf("Expected 'Hello world', got '%s'", result)
}
}
// ---------------------------------------------------------------------------
// Cloud mode (Ollama Cloud: /v1/chat/completions)
// ---------------------------------------------------------------------------
func TestCloud_Generate(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Errorf("Expected /v1/chat/completions, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
"choices": [{"message": {"role": "assistant", "content": "Hello from Ollama Cloud!"}}]
}`))
}))
defer srv.Close()
p := NewProvider(ai.WithBaseURL(srv.URL), ai.WithModel("gemma4:31b-cloud"), ai.WithAPIKey("test-key"))
p.cloudOverride = true
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "Hi",
SystemPrompt: "You are helpful",
})
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
if resp.Reply != "Hello from Ollama Cloud!" {
t.Errorf("Expected 'Hello from Ollama Cloud!', got '%s'", resp.Reply)
}
if resp.Usage.TotalTokens != 15 {
t.Errorf("Expected total tokens 15, got %d", resp.Usage.TotalTokens)
}
}
func TestCloud_GenerateWithToolCall(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
if callCount == 1 {
w.Write([]byte(`{
"choices": [{"message": {
"role": "assistant",
"content": "",
"tool_calls": [{"id": "call_1", "function": {"name": "search", "arguments": "{\"query\":\"go interfaces\"}"}}]
}}]
}`))
} else {
w.Write([]byte(`{
"choices": [{"message": {"role": "assistant", "content": "Go interfaces are implicit."}}]
}`))
}
}))
defer srv.Close()
handler := func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
return ai.ToolResult{ID: call.ID, Content: `{"results": ["Go interfaces are implicit"]}`}
}
p := NewProvider(
ai.WithBaseURL(srv.URL),
ai.WithModel("gemma4:31b-cloud"),
ai.WithAPIKey("test-key"),
ai.WithToolHandler(handler),
)
p.cloudOverride = true
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "Search for Go interfaces",
Tools: []ai.Tool{{
Name: "search",
Description: "Search the knowledge base",
Properties: map[string]any{"query": map[string]any{"type": "string"}},
}},
})
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
if len(resp.ToolCalls) == 0 {
t.Error("Expected tool calls")
}
if resp.Answer != "Go interfaces are implicit." {
t.Errorf("Expected final answer, got '%s'", resp.Answer)
}
}
func TestCloud_Stream(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n"))
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\" cloud\"}}]}\n\n"))
w.Write([]byte("data: [DONE]\n\n"))
}))
defer srv.Close()
p := NewProvider(
ai.WithBaseURL(srv.URL),
ai.WithModel("gemma4:31b-cloud"),
ai.WithAPIKey("test-key"),
)
p.cloudOverride = true
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hi"})
if err != nil {
t.Fatalf("Stream failed: %v", err)
}
defer stream.Close()
var chunks []string
for {
resp, err := stream.Recv()
if err != nil {
break
}
if resp.Reply != "" {
chunks = append(chunks, resp.Reply)
}
}
result := strings.Join(chunks, "")
if result != "Hello cloud" {
t.Errorf("Expected 'Hello cloud', got '%s'", result)
}
}
// ---------------------------------------------------------------------------
// Error handling
// ---------------------------------------------------------------------------
func TestProvider_APIError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error": "model not found"}`))
}))
defer srv.Close()
p := NewProvider(ai.WithBaseURL(srv.URL), ai.WithModel("nonexistent"))
_, err := p.Generate(context.Background(), &ai.Request{Prompt: "Hi"})
if err == nil {
t.Error("Expected error on API failure")
}
if !strings.Contains(err.Error(), "API error") {
t.Errorf("Expected 'API error' in message, got '%s'", err.Error())
}
}
+114 -3
View File
@@ -2,6 +2,7 @@
package openai
import (
"bufio"
"bytes"
"context"
"encoding/json"
@@ -20,6 +21,7 @@ func init() {
ai.RegisterImage("openai", func(opts ...ai.Option) ai.ImageModel {
return NewProvider(opts...)
})
ai.RegisterStream("openai")
}
// Provider implements the ai.Model interface for OpenAI
@@ -83,7 +85,12 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
// Build messages
messages := []map[string]any{
{"role": "system", "content": req.SystemPrompt},
{"role": "user", "content": req.Prompt},
}
for _, m := range req.Messages {
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
}
// Build initial request
@@ -91,6 +98,9 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
"model": p.opts.Model,
"messages": messages,
}
if p.opts.MaxTokens > 0 {
apiReq["max_tokens"] = p.opts.MaxTokens
}
if len(openaiTools) > 0 {
apiReq["tools"] = openaiTools
@@ -140,9 +150,110 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
return resp, nil
}
// Stream generates a streaming response (not yet implemented)
// Stream generates a streaming response from the OpenAI chat completions API.
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")
messages := []map[string]any{
{"role": "system", "content": req.SystemPrompt},
}
for _, m := range req.Messages {
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
}
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
"stream": true,
"stream_options": map[string]any{"include_usage": true},
}
if p.opts.MaxTokens > 0 {
apiReq["max_tokens"] = p.opts.MaxTokens
}
reqBody, err := json.Marshal(apiReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create stream request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "text/event-stream")
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("stream API request failed: %w", err)
}
if httpResp.StatusCode != http.StatusOK {
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
return nil, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(respBody))
}
return &openAIStream{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
}
type openAIStream struct {
body io.ReadCloser
scanner *bufio.Scanner
closed bool
}
func (s *openAIStream) Recv() (*ai.Response, error) {
for s.scanner.Scan() {
line := strings.TrimSpace(s.scanner.Text())
if line == "" || strings.HasPrefix(line, ":") {
continue
}
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "[DONE]" {
return nil, io.EOF
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
}
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
return &ai.Response{Reply: chunk.Choices[0].Delta.Content}, nil
}
// Final chunk (after include_usage) carries token usage and no content.
if chunk.Usage != nil {
return &ai.Response{Usage: ai.Usage{
InputTokens: chunk.Usage.PromptTokens,
OutputTokens: chunk.Usage.CompletionTokens,
TotalTokens: chunk.Usage.TotalTokens,
}}, nil
}
continue
}
if err := s.scanner.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}
func (s *openAIStream) Close() error {
if s.closed {
return nil
}
s.closed = true
return s.body.Close()
}
// callAPI makes an HTTP request to the OpenAI API
+93 -7
View File
@@ -2,7 +2,13 @@ package openai
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"go-micro.dev/v6/ai"
)
@@ -80,16 +86,96 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
}
}
func TestProvider_Stream_NotImplemented(t *testing.T) {
p := NewProvider()
func TestProvider_Stream(t *testing.T) {
var sawStream bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Fatalf("path = %s, want /v1/chat/completions", r.URL.Path)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
sawStream, _ = body["stream"].(bool)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer ts.Close()
req := &ai.Request{
Prompt: "Hello",
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
if !sawStream {
t.Fatal("stream request did not set stream=true")
}
_, err := p.Stream(context.Background(), req)
if err == nil {
t.Error("Expected error for unimplemented streaming, got nil")
first, err := stream.Recv()
if err != nil || first.Reply != "hel" {
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
}
second, err := stream.Recv()
if err != nil || second.Reply != "lo" {
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
}
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
t.Fatalf("final error = %v, want EOF", err)
}
}
func TestProvider_StreamPropagatesMalformedChunk(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {bad json}\n\n"))
}))
defer ts.Close()
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
if _, err := stream.Recv(); err == nil {
t.Fatal("Recv returned nil error for malformed chunk")
}
}
func TestProvider_StreamCloseReleasesResponse(t *testing.T) {
released := make(chan struct{})
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
<-r.Context().Done()
close(released)
}))
defer ts.Close()
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
first, err := stream.Recv()
if err != nil || first.Reply != "hel" {
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
}
if err := stream.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
select {
case <-released:
case <-time.After(time.Second):
t.Fatal("server did not observe closed stream request")
}
}
+10
View File
@@ -16,6 +16,8 @@ type Options struct {
BaseURL string
// ToolHandler handles tool calls (optional, for automatic tool execution)
ToolHandler ToolHandler
// MaxTokens caps the length of the response (0 = provider default)
MaxTokens int
}
// GenerateOptions for generate call
@@ -91,3 +93,11 @@ func WithTools(t *Tools) Option {
}
}
}
// WithMaxTokens caps the number of tokens in the response. 0 leaves the
// provider default in place.
func WithMaxTokens(n int) Option {
return func(o *Options) {
o.MaxTokens = n
}
}
+115 -26
View File
@@ -13,9 +13,34 @@ type StatusCoder interface {
StatusCode() int
}
// RetryAfterCoder is implemented by provider errors that expose a server
// supplied retry delay, such as HTTP Retry-After on a 429/503 response.
type RetryAfterCoder interface {
RetryAfter() time.Duration
}
// ErrorKind classifies provider-boundary failures into stable buckets callers
// can inspect without parsing provider-specific error strings.
type ErrorKind string
const (
ErrorKindUnknown ErrorKind = "unknown"
ErrorKindCanceled ErrorKind = "canceled"
ErrorKindTimeout ErrorKind = "timeout"
ErrorKindRateLimited ErrorKind = "rate_limited"
ErrorKindUnavailable ErrorKind = "unavailable"
ErrorKindProvider ErrorKind = "provider"
)
// ClassifiedError is implemented by errors that expose a stable ErrorKind.
type ClassifiedError interface {
ErrorKind() ErrorKind
}
// RetryError is returned when Generate is retried and still fails.
type RetryError struct {
Attempts int
Kind ErrorKind
Err error
}
@@ -23,7 +48,7 @@ func (e *RetryError) Error() string {
if e == nil {
return ""
}
return fmt.Sprintf("ai generate failed after %d attempt(s): %v", e.Attempts, e.Err)
return fmt.Sprintf("ai generate failed after %d attempt(s) (%s): %v", e.Attempts, e.ErrorKind(), e.Err)
}
func (e *RetryError) Unwrap() error {
@@ -33,6 +58,13 @@ func (e *RetryError) Unwrap() error {
return e.Err
}
func (e *RetryError) ErrorKind() ErrorKind {
if e == nil || e.Kind == "" {
return ErrorKindUnknown
}
return e.Kind
}
// GeneratePolicy controls timeout and retry behavior for a model call.
type GeneratePolicy struct {
Timeout time.Duration
@@ -60,20 +92,30 @@ func GenerateWithRetry(ctx context.Context, m Model, req *Request, policy Genera
if policy.Timeout > 0 {
callCtx, cancel = context.WithTimeout(ctx, policy.Timeout)
}
if info, ok := RunInfoFrom(callCtx); ok {
info.Attempt = attempt
info.MaxAttempts = policy.MaxAttempts
callCtx = WithRunInfo(callCtx, info)
}
resp, err := m.Generate(callCtx, req, opts...)
cancel()
// Caller cancellation/deadline always wins and is not retried, even if
// a provider or tool loop swallowed the canceled tool result and returned
// a final response. This keeps agent runs from appearing successful after
// their controlling context was abandoned.
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
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}
transient := IsTransientError(err)
if attempt == policy.MaxAttempts || !transient {
if attempt > 1 || transient {
return nil, &RetryError{Attempts: attempt, Kind: ClassifyError(err), Err: err}
}
return nil, err
}
@@ -81,16 +123,7 @@ func GenerateWithRetry(ctx context.Context, m Model, req *Request, policy Genera
// Always back off between retries — exponential and capped — so an
// opt-in retry can never become a tight loop hammering the provider,
// even if Backoff was left at zero.
backoff := 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
}
backoff := retryBackoff(err, attempt, policy.Backoff)
t := time.NewTimer(backoff)
select {
case <-ctx.Done():
@@ -101,25 +134,81 @@ func GenerateWithRetry(ctx context.Context, m Model, req *Request, policy Genera
case <-t.C:
}
}
return nil, &RetryError{Attempts: policy.MaxAttempts, Err: last}
return nil, &RetryError{Attempts: policy.MaxAttempts, Kind: ClassifyError(last), Err: last}
}
// IsTransientError reports whether err is worth retrying at the provider boundary.
func IsTransientError(err error) bool {
func retryBackoff(err error, attempt int, base time.Duration) time.Duration {
backoff := base
if backoff <= 0 {
backoff = 200 * time.Millisecond
}
if shift := attempt - 1; shift > 0 {
backoff <<= shift
}
if backoff > 30*time.Second {
backoff = 30 * time.Second
}
var retryAfter RetryAfterCoder
if errors.As(err, &retryAfter) {
if delay := retryAfter.RetryAfter(); delay > backoff {
backoff = delay
}
}
if backoff > 30*time.Second {
return 30 * time.Second
}
return backoff
}
// ClassifyError maps provider and context failures to stable operational kinds.
func ClassifyError(err error) ErrorKind {
if err == nil {
return false
return ""
}
var classified ClassifiedError
if errors.As(err, &classified) {
if kind := classified.ErrorKind(); kind != "" {
return kind
}
}
if errors.Is(err, context.Canceled) {
return false
return ErrorKindCanceled
}
if errors.Is(err, context.DeadlineExceeded) {
return true
return ErrorKindTimeout
}
var sc StatusCoder
if errors.As(err, &sc) {
code := sc.StatusCode()
return code == 429 || code >= 500
switch {
case code == 429:
return ErrorKindRateLimited
case code >= 500:
return ErrorKindUnavailable
case code > 0:
return ErrorKindProvider
}
}
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")
switch {
case strings.Contains(msg, "rate limit") || strings.Contains(msg, "too many requests"):
return ErrorKindRateLimited
case strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline"):
return ErrorKindTimeout
case strings.Contains(msg, "temporar") || strings.Contains(msg, "unavailable"):
return ErrorKindUnavailable
default:
return ErrorKindUnknown
}
}
// IsTransientError reports whether err is worth retrying at the provider boundary.
func IsTransientError(err error) bool {
switch ClassifyError(err) {
case ErrorKindTimeout, ErrorKindRateLimited, ErrorKindUnavailable:
return true
default:
return false
}
}
+223
View File
@@ -0,0 +1,223 @@
package ai
import (
"context"
"errors"
"testing"
"time"
)
type retryModel struct {
generate func(context.Context, *Request, ...GenerateOption) (*Response, error)
}
func (m retryModel) Init(...Option) error { return nil }
func (m retryModel) Options() Options { return Options{} }
func (m retryModel) Generate(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error) {
return m.generate(ctx, req, opts...)
}
func (m retryModel) Stream(context.Context, *Request, ...GenerateOption) (Stream, error) {
return nil, ErrStreamingUnsupported
}
func (m retryModel) String() string { return "retry-test" }
func TestGenerateWithRetryRetriesTransientErrors(t *testing.T) {
attempts := 0
model := retryModel{generate: func(context.Context, *Request, ...GenerateOption) (*Response, error) {
attempts++
if attempts == 1 {
return nil, errors.New("temporary provider outage")
}
return &Response{Reply: "ok"}, nil
}}
resp, err := GenerateWithRetry(context.Background(), model, &Request{Prompt: "hi"}, GeneratePolicy{
MaxAttempts: 2,
Backoff: time.Millisecond,
})
if err != nil {
t.Fatalf("GenerateWithRetry returned error: %v", err)
}
if resp.Reply != "ok" {
t.Fatalf("response reply = %q, want ok", resp.Reply)
}
if attempts != 2 {
t.Fatalf("attempts = %d, want 2", attempts)
}
}
func TestGenerateWithRetryDoesNotRetryCallerCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
attempts := 0
model := retryModel{generate: func(context.Context, *Request, ...GenerateOption) (*Response, error) {
attempts++
cancel()
return nil, errors.New("temporary provider outage")
}}
_, err := GenerateWithRetry(ctx, model, &Request{Prompt: "hi"}, GeneratePolicy{
MaxAttempts: 3,
Backoff: time.Millisecond,
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context.Canceled", err)
}
if attempts != 1 {
t.Fatalf("attempts = %d, want 1", attempts)
}
}
func TestGenerateWithRetryHonorsPerAttemptTimeout(t *testing.T) {
attempts := 0
model := retryModel{generate: func(ctx context.Context, _ *Request, _ ...GenerateOption) (*Response, error) {
attempts++
<-ctx.Done()
return nil, ctx.Err()
}}
_, err := GenerateWithRetry(context.Background(), model, &Request{Prompt: "hi"}, GeneratePolicy{
Timeout: time.Millisecond,
MaxAttempts: 2,
Backoff: time.Millisecond,
})
var retryErr *RetryError
if !errors.As(err, &retryErr) {
t.Fatalf("error = %T %[1]v, want RetryError", err)
}
if retryErr.Attempts != 2 {
t.Fatalf("retry attempts = %d, want 2", retryErr.Attempts)
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("error = %v, want context.DeadlineExceeded", err)
}
if attempts != 2 {
t.Fatalf("attempts = %d, want 2", attempts)
}
}
func TestGenerateWithRetryAddsAttemptMetadataToRunInfo(t *testing.T) {
var got []RunInfo
model := retryModel{generate: func(ctx context.Context, _ *Request, _ ...GenerateOption) (*Response, error) {
info, ok := RunInfoFrom(ctx)
if !ok {
t.Fatal("RunInfo missing from attempt context")
}
got = append(got, info)
if info.Attempt == 1 {
return nil, errors.New("temporary provider outage")
}
return &Response{Reply: "ok"}, nil
}}
ctx := WithRunInfo(context.Background(), RunInfo{RunID: "run-1", Agent: "worker"})
_, err := GenerateWithRetry(ctx, model, &Request{Prompt: "hi"}, GeneratePolicy{
MaxAttempts: 2,
Backoff: time.Millisecond,
})
if err != nil {
t.Fatalf("GenerateWithRetry returned error: %v", err)
}
if len(got) != 2 {
t.Fatalf("attempt contexts = %d, want 2", len(got))
}
for i, info := range got {
wantAttempt := i + 1
if info.Attempt != wantAttempt {
t.Fatalf("attempt %d RunInfo.Attempt = %d, want %d", i, info.Attempt, wantAttempt)
}
if info.MaxAttempts != 2 {
t.Fatalf("attempt %d RunInfo.MaxAttempts = %d, want 2", i, info.MaxAttempts)
}
if info.RunID != "run-1" || info.Agent != "worker" {
t.Fatalf("attempt %d RunInfo identity = (%q, %q), want (run-1, worker)", i, info.RunID, info.Agent)
}
}
}
type statusErr int
func (e statusErr) Error() string { return "provider status" }
func (e statusErr) StatusCode() int { return int(e) }
type retryAfterErr struct {
delay time.Duration
}
func (e retryAfterErr) Error() string { return "rate limit exceeded" }
func (e retryAfterErr) StatusCode() int { return 429 }
func (e retryAfterErr) RetryAfter() time.Duration { return e.delay }
func TestClassifyErrorDistinguishesOperationalOutcomes(t *testing.T) {
tests := []struct {
name string
err error
want ErrorKind
}{
{name: "canceled", err: context.Canceled, want: ErrorKindCanceled},
{name: "timeout", err: context.DeadlineExceeded, want: ErrorKindTimeout},
{name: "rate limit status", err: statusErr(429), want: ErrorKindRateLimited},
{name: "unavailable status", err: statusErr(503), want: ErrorKindUnavailable},
{name: "provider status", err: statusErr(400), want: ErrorKindProvider},
{name: "rate limit text", err: errors.New("rate limit exceeded"), want: ErrorKindRateLimited},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ClassifyError(tt.err); got != tt.want {
t.Fatalf("ClassifyError() = %q, want %q", got, tt.want)
}
})
}
}
func TestGenerateWithRetryExposesRetryErrorKind(t *testing.T) {
model := retryModel{generate: func(context.Context, *Request, ...GenerateOption) (*Response, error) {
return nil, statusErr(429)
}}
_, err := GenerateWithRetry(context.Background(), model, &Request{Prompt: "hi"}, GeneratePolicy{
MaxAttempts: 2,
Backoff: time.Millisecond,
})
var retryErr *RetryError
if !errors.As(err, &retryErr) {
t.Fatalf("error = %T %[1]v, want RetryError", err)
}
if retryErr.ErrorKind() != ErrorKindRateLimited {
t.Fatalf("retry kind = %q, want %q", retryErr.ErrorKind(), ErrorKindRateLimited)
}
if !errors.Is(err, statusErr(429)) {
t.Fatalf("retry error does not unwrap provider status: %v", err)
}
}
func TestGenerateWithRetryHonorsRetryAfterWhenLongerThanBackoff(t *testing.T) {
attempts := 0
model := retryModel{generate: func(context.Context, *Request, ...GenerateOption) (*Response, error) {
attempts++
if attempts == 1 {
return nil, retryAfterErr{delay: 25 * time.Millisecond}
}
return &Response{Reply: "ok"}, nil
}}
start := time.Now()
resp, err := GenerateWithRetry(context.Background(), model, &Request{Prompt: "hi"}, GeneratePolicy{
MaxAttempts: 2,
Backoff: time.Millisecond,
})
if err != nil {
t.Fatalf("GenerateWithRetry returned error: %v", err)
}
if resp.Reply != "ok" {
t.Fatalf("reply = %q, want ok", resp.Reply)
}
if elapsed := time.Since(start); elapsed < 20*time.Millisecond {
t.Fatalf("retry delay = %s, want RetryAfter delay to dominate base backoff", elapsed)
}
}
func TestGenerateWithRetryCapsRetryAfter(t *testing.T) {
if got := retryBackoff(retryAfterErr{delay: time.Minute}, 1, time.Millisecond); got != 30*time.Second {
t.Fatalf("retryBackoff() = %s, want 30s cap", got)
}
}
+310
View File
@@ -0,0 +1,310 @@
package ai_test
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"reflect"
"strings"
"testing"
"time"
"go-micro.dev/v6/ai"
_ "go-micro.dev/v6/ai/anthropic"
_ "go-micro.dev/v6/ai/atlascloud"
_ "go-micro.dev/v6/ai/gemini"
_ "go-micro.dev/v6/ai/groq"
_ "go-micro.dev/v6/ai/minimax"
_ "go-micro.dev/v6/ai/mistral"
_ "go-micro.dev/v6/ai/openai"
_ "go-micro.dev/v6/ai/together"
)
func TestStreamProvidersConformToOpenAICompatibleSSE(t *testing.T) {
providers := conformingStreamProviders(t)
for _, provider := range providers {
provider := provider
t.Run(provider, func(t *testing.T) {
var sawRequest bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawRequest = true
if r.URL.Path != "/v1/chat/completions" {
t.Fatalf("path = %s, want /v1/chat/completions", r.URL.Path)
}
if got := r.Header.Get("Accept"); got != "text/event-stream" {
t.Fatalf("Accept = %q, want text/event-stream", got)
}
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
t.Fatalf("Authorization = %q, want bearer API key", got)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
if body["model"] == "" {
t.Fatal("request omitted model")
}
if body["stream"] != true {
t.Fatalf("stream = %#v, want true", body["stream"])
}
streamOptions, ok := body["stream_options"].(map[string]any)
if !ok || streamOptions["include_usage"] != true {
t.Fatalf("stream_options = %#v, want include_usage=true", body["stream_options"])
}
messages, ok := body["messages"].([]any)
if !ok || len(messages) != 4 {
t.Fatalf("messages = %#v, want system + history + prompt", body["messages"])
}
wantRoles := []string{"system", "user", "assistant", "user"}
for i, wantRole := range wantRoles {
message, ok := messages[i].(map[string]any)
if !ok || message["role"] != wantRole {
t.Fatalf("message[%d] = %#v, want role %q", i, messages[i], wantRole)
}
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(": keepalive\n\n"))
_, _ = w.Write([]byte("event: ignored\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":2,\"total_tokens\":5}}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer ts.Close()
model := ai.New(provider, ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
if model == nil {
t.Fatalf("ai.New(%q) returned nil", provider)
}
stream, err := model.Stream(context.Background(), &ai.Request{
SystemPrompt: "system",
Messages: []ai.Message{
{Role: "user", Content: "previous question"},
{Role: "assistant", Content: "previous answer"},
},
Prompt: "current question",
})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
if !sawRequest {
t.Fatal("server did not receive stream request")
}
assertStreamReply(t, stream, "hel")
assertStreamReply(t, stream, "lo")
usage, err := stream.Recv()
if err != nil {
t.Fatalf("usage chunk error: %v", err)
}
if usage.Reply != "" || usage.Usage != (ai.Usage{InputTokens: 3, OutputTokens: 2, TotalTokens: 5}) {
t.Fatalf("usage chunk = %#v", usage)
}
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
t.Fatalf("final error = %v, want EOF", err)
}
})
}
}
func TestStreamProvidersCloseCancelsInFlightRequest(t *testing.T) {
for _, provider := range conformingStreamProviders(t) {
provider := provider
t.Run(provider, func(t *testing.T) {
released := make(chan struct{})
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
<-r.Context().Done()
close(released)
}))
defer ts.Close()
stream, err := ai.New(provider, ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL)).Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
assertStreamReply(t, stream, "hel")
if err := stream.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
if err := stream.Close(); err != nil {
t.Fatalf("second Close returned error: %v", err)
}
select {
case <-released:
case <-time.After(time.Second):
t.Fatal("server did not observe canceled stream request")
}
})
}
}
func TestStreamProvidersPropagateProviderErrors(t *testing.T) {
for _, provider := range conformingStreamProviders(t) {
provider := provider
t.Run(provider, func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "upstream quota exhausted", http.StatusTooManyRequests)
}))
defer ts.Close()
stream, err := ai.New(provider, ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL)).Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err == nil {
_ = stream.Close()
t.Fatal("Stream returned nil error for provider failure")
}
if !strings.Contains(err.Error(), "429") || !strings.Contains(err.Error(), "upstream quota exhausted") {
t.Fatalf("Stream error = %v, want provider status and body", err)
}
if strings.Contains(err.Error(), "test-key") {
t.Fatal("provider error leaked API key")
}
})
}
}
func TestStreamProvidersHonorCanceledContextBeforeRequest(t *testing.T) {
for _, provider := range conformingStreamProviders(t) {
provider := provider
t.Run(provider, func(t *testing.T) {
var sawRequest bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawRequest = true
http.Error(w, "unexpected request", http.StatusInternalServerError)
}))
defer ts.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel()
stream, err := ai.New(provider, ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL)).Stream(ctx, &ai.Request{Prompt: "Hello"})
if err == nil {
_ = stream.Close()
t.Fatal("Stream returned nil error for canceled context")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("Stream error = %v, want context.Canceled", err)
}
if sawRequest {
t.Fatal("provider sent request after context was already canceled")
}
})
}
}
func TestConfiguredProviderStreamsSkipWithoutCredentials(t *testing.T) {
for _, tc := range []struct {
provider string
keyEnv string
modelEnv string
}{
{provider: "openai", keyEnv: "OPENAI_API_KEY", modelEnv: "OPENAI_MODEL"},
{provider: "groq", keyEnv: "GROQ_API_KEY", modelEnv: "GROQ_MODEL"},
{provider: "mistral", keyEnv: "MISTRAL_API_KEY", modelEnv: "MISTRAL_MODEL"},
{provider: "together", keyEnv: "TOGETHER_API_KEY", modelEnv: "TOGETHER_MODEL"},
{provider: "atlascloud", keyEnv: "ATLASCLOUD_API_KEY", modelEnv: "ATLASCLOUD_MODEL"},
{provider: "anthropic", keyEnv: "ANTHROPIC_API_KEY", modelEnv: "ANTHROPIC_MODEL"},
} {
tc := tc
t.Run(tc.provider, func(t *testing.T) {
key := os.Getenv(tc.keyEnv)
if key == "" {
t.Skipf("%s not set; skipping configured provider stream check", tc.keyEnv)
}
opts := []ai.Option{ai.WithAPIKey(key)}
if model := os.Getenv(tc.modelEnv); model != "" {
opts = append(opts, ai.WithModel(model))
}
stream, err := ai.New(tc.provider, opts...).Stream(context.Background(), &ai.Request{Prompt: "Reply with exactly: ok"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
deadline := time.After(30 * time.Second)
for {
select {
case <-deadline:
t.Fatal("timed out waiting for provider stream chunk")
default:
}
chunk, err := stream.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
t.Fatal("provider stream ended without content")
}
t.Fatalf("Recv returned error: %v", err)
}
if chunk.Reply != "" {
return
}
}
})
}
}
func TestUnsupportedProvidersReturnStreamingUnsupportedAndStayUnregistered(t *testing.T) {
for _, provider := range []string{"gemini"} {
provider := provider
t.Run(provider, func(t *testing.T) {
if caps := ai.ProviderCapabilities(provider); caps.Stream {
t.Fatalf("ProviderCapabilities(%q).Stream = true, want false", provider)
}
_, err := ai.New(provider, ai.WithAPIKey("test-key")).Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
}
if err != nil && strings.Contains(err.Error(), "test-key") {
t.Fatal("streaming unsupported error leaked API key")
}
})
}
}
func conformingStreamProviders(t *testing.T) []string {
t.Helper()
providers := ai.RegisteredProviders("stream")
allowed := map[string]struct{}{
"atlascloud": {},
"groq": {},
"minimax": {},
"mistral": {},
"openai": {},
"together": {},
}
var out []string
for _, provider := range providers {
if _, ok := allowed[provider]; ok {
out = append(out, provider)
}
}
want := []string{"atlascloud", "groq", "minimax", "mistral", "openai", "together"}
if !reflect.DeepEqual(out, want) {
t.Fatalf("conforming stream providers = %#v, want %#v (registered stream providers: %#v)", out, want, providers)
}
return out
}
func assertStreamReply(t *testing.T, stream ai.Stream, want string) {
t.Helper()
chunk, err := stream.Recv()
if err != nil {
t.Fatalf("Recv error = %v, want reply %q", err, want)
}
if chunk.Reply != want {
t.Fatalf("Reply = %q, want %q", chunk.Reply, want)
}
}
+3 -1
View File
@@ -22,12 +22,14 @@ import (
"strings"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/ai/internal/openaiapi"
)
func init() {
ai.Register("together", func(opts ...ai.Option) ai.Model {
return NewProvider(opts...)
})
ai.RegisterStream("together")
}
type Provider struct {
@@ -119,7 +121,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
}
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("streaming not yet implemented for together provider")
return openaiapi.Stream(ctx, p.opts, req, "/v1/chat/completions")
}
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
+43 -3
View File
@@ -2,6 +2,11 @@ package together
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"go-micro.dev/v6/ai"
@@ -39,9 +44,44 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
}
}
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_Stream(t *testing.T) {
var sawStream bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Fatalf("path = %s, want /v1/chat/completions", r.URL.Path)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
sawStream, _ = body["stream"].(bool)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer ts.Close()
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
if !sawStream {
t.Fatal("stream request did not set stream=true")
}
first, err := stream.Recv()
if err != nil || first.Reply != "hel" {
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
}
second, err := stream.Recv()
if err != nil || second.Reply != "lo" {
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
}
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
t.Fatalf("final error = %v, want EOF", err)
}
}
+72
View File
@@ -52,6 +52,26 @@ This starts:
Open http://localhost:8080 to see your services and call them from the browser.
Call the generated service from another terminal:
```
curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call \
-H 'Content-Type: application/json' -d '{"name":"World"}'
```
## First agent on-ramp
Once the scaffold → run → call path works, ask the installed CLI for the
provider-free agent path:
```
micro agent demo
micro examples
```
Those commands point at the smallest mock-model first-agent example, the no-secret
transcript, and the support app before you add provider-backed chat.
### Output
```
@@ -625,3 +645,55 @@ Scopes provide fine-grained access control over which tokens can call which serv
The gateway's scope system uses `auth.Account` from the go-micro framework. Scopes on accounts are the same `[]string` field used by the framework's `auth.Rules` and `wrapper/auth` package. The gateway stores scope requirements in the default store under `endpoint-scopes/<service>.<endpoint>` keys and checks them on every HTTP request.
For service-level (RPC) auth within the go-micro mesh, use the `wrapper/auth` package which provides `auth.Rules` with priority-based access control. See the [auth wrapper documentation](../../wrapper/auth/README.md) for details.
## Self-improving loop (`micro loop`)
Turn a repository into a self-improving one: GitHub Actions workflows that
dispatch a coding agent to plan, build, and triage — gated by CI. This is the
same loop that maintains go-micro itself, generalized so any repo (and any
@mention-driven agent) can use it.
```bash
micro loop init # scaffold the loop into the current repo
micro loop verify # check a repo is wired correctly
```
`micro loop init` writes the selected roles' workflows, their prompts, and a queue. Choose roles with `--roles` (default `planner,builder,triage`; `--roles all` for everything):
| Role | Workflow | What it does |
|------|----------|--------------|
| Planner | `loop-planner.yml` | Keeps a ranked queue in `.github/loop/PRIORITIES.md` |
| Builder | `loop-builder.yml` | Builds the top open item as a single-concern PR, auto-merged on green CI |
| Triage | `loop-triage.yml` | Turns CI failures into scoped fix issues, back into the queue |
| Coherence | `loop-coherence.yml` | Keeps README/docs/CHANGELOG aligned with the North Star *(opt-in)* |
| Security | `loop-security.yml` | Audits for vulnerabilities and files them; never auto-merges fixes, never publishes exploit detail *(opt-in)* |
| Release | `loop-release.yml` | Cuts the next patch tag when the branch has new commits *(opt-in)* |
The workflows are the **mechanism**; each dispatch role's instruction is an editable file in `.github/loop/prompts/` — the **policy**. Edit those prompts (and `.github/loop/NORTH_STAR.md`) to steer the loop without touching the CLI. That split is what lets go-micro itself use `micro loop` while keeping its own richer prompts.
Common flags:
```bash
micro loop init \
--roles all \
--agent @codex \
--token-secret LOOP_TOKEN \
--branch main \
--ci-workflow CI
```
- `--roles`: which roles to scaffold (`planner,builder,triage`, or `all`)
- `--agent`: how the workflows summon the agent (an `@mention`)
- `--token-secret`: repo secret holding the driving user PAT
- `--branch`: base branch for the loop's PRs
- `--ci-workflow`: `name:` of the CI workflow triage watches
- `--tag-prefix`: tag prefix the release role matches and bumps (default `v`)
Two things the CLI can't do for you (and `micro loop verify` reminds you of):
1. **Add the token secret.** The agent ignores `@mentions` from the
`github-actions` bot, so dispatch posts as a real user via a PAT stored in
the `--token-secret` repo secret. The workflows no-op until it's set.
2. **Set branch protection.** Require the CI checks with **0 approving reviews**
so the builder's native auto-merge lands PRs the moment CI is green — that
green-CI gate is the loop's only safety mechanism, so keep the suite strong.
+72
View File
@@ -0,0 +1,72 @@
package ai
import (
"encoding/json"
"fmt"
"io"
"github.com/urfave/cli/v2"
goai "go-micro.dev/v6/ai"
_ "go-micro.dev/v6/ai/anthropic"
_ "go-micro.dev/v6/ai/atlascloud"
_ "go-micro.dev/v6/ai/gemini"
_ "go-micro.dev/v6/ai/groq"
_ "go-micro.dev/v6/ai/mistral"
_ "go-micro.dev/v6/ai/openai"
_ "go-micro.dev/v6/ai/together"
"go-micro.dev/v6/cmd"
)
func init() {
cmd.Register(&cli.Command{
Name: "ai",
Usage: "Inspect AI provider support",
Subcommands: []*cli.Command{{
Name: "providers",
Usage: "Print the registered AI provider capability matrix",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "json",
Usage: "Print the capability matrix as JSON",
},
},
Action: providersAction,
}},
})
}
func providersAction(c *cli.Context) error {
rows := goai.CapabilityRows()
if c.Bool("json") {
return writeProviderJSON(c.App.Writer, rows)
}
writeProviderMatrix(c.App.Writer, rows)
return nil
}
func writeProviderJSON(w io.Writer, rows []goai.CapabilityRow) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(rows)
}
func writeProviderMatrix(w io.Writer, rows []goai.CapabilityRow) {
const check = "✓"
fmt.Fprintln(w, "Provider Model Image Video")
fmt.Fprintln(w, "-------- ----- ----- -----")
for _, row := range rows {
fmt.Fprintf(w, "%-11s %-6s %-6s %-6s\n",
row.Provider,
mark(row.Model, check),
mark(row.Image, check),
mark(row.Video, check),
)
}
}
func mark(ok bool, value string) string {
if ok {
return value
}
return "-"
}
+53
View File
@@ -0,0 +1,53 @@
package ai
import (
"bytes"
"encoding/json"
"strings"
"testing"
goai "go-micro.dev/v6/ai"
)
func TestWriteProviderMatrix(t *testing.T) {
rows := []goai.CapabilityRow{
{Provider: "atlascloud", Capabilities: goai.Capabilities{Model: true, Image: true, Video: true}},
{Provider: "openai", Capabilities: goai.Capabilities{Model: true, Image: true}},
}
var out bytes.Buffer
writeProviderMatrix(&out, rows)
got := out.String()
for _, want := range []string{
"Provider Model Image Video",
"atlascloud ✓ ✓ ✓",
"openai ✓ ✓ -",
} {
if !strings.Contains(got, want) {
t.Fatalf("matrix output missing %q:\n%s", want, got)
}
}
}
func TestWriteProviderJSON(t *testing.T) {
rows := []goai.CapabilityRow{
{Provider: "openai", Capabilities: goai.Capabilities{Model: true, Image: true}},
}
var out bytes.Buffer
if err := writeProviderJSON(&out, rows); err != nil {
t.Fatalf("writeProviderJSON returned error: %v", err)
}
var got []goai.CapabilityRow
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("JSON output did not decode: %v\n%s", err, out.String())
}
if len(got) != 1 || got[0].Provider != "openai" || !got[0].Model || !got[0].Image || got[0].Video {
t.Fatalf("decoded JSON = %#v, want openai model+image", got)
}
if !strings.HasSuffix(out.String(), "\n") {
t.Fatalf("JSON output should end with newline: %q", out.String())
}
}
+45 -2
View File
@@ -10,7 +10,9 @@ import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
@@ -79,6 +81,7 @@ Examples:
&cli.StringFlag{Name: "model", Usage: "Model name (uses provider default if unset)", EnvVars: []string{"MICRO_AI_MODEL"}},
&cli.StringFlag{Name: "base_url", Usage: "Override the provider's base URL", EnvVars: []string{"MICRO_AI_BASE_URL"}},
&cli.StringFlag{Name: "prompt", Usage: "Send a single prompt and exit (non-interactive)"},
&cli.BoolFlag{Name: "stream", Usage: "Stream model output as it is generated when the provider supports it"},
},
Action: run,
})
@@ -102,6 +105,7 @@ type session struct {
sysPrompt string
procs []*exec.Cmd
agents map[string]agentInfo
stream bool
// Built-in agent capabilities (plan, delegate), shared with the
// agent package so the direct-service fallback has the same tools a
@@ -311,6 +315,7 @@ func run(c *cli.Context) error {
modelName := c.String("model")
baseURL := c.String("base_url")
singlePrompt := c.String("prompt")
streamOutput := c.Bool("stream")
if provider == "" {
provider = ai.AutoDetectProvider(baseURL)
@@ -347,6 +352,7 @@ func run(c *cli.Context) error {
hist: ai.NewHistory(50),
builtinTools: builtinTools,
builtinHandle: builtinHandle,
stream: streamOutput,
}
s.refreshTools()
@@ -449,12 +455,21 @@ func (s *session) ask(ctx context.Context, prompt string) error {
// Fallback: direct service access (no agents)
s.hist.Add("user", prompt)
resp, err := s.model.Generate(ctx, &ai.Request{
req := &ai.Request{
Prompt: prompt,
SystemPrompt: s.sysPrompt,
Tools: s.toolList,
Messages: s.hist.Messages(),
})
}
if s.stream {
if err := s.askStream(ctx, req); err == nil {
return nil
} else if !errors.Is(err, ai.ErrStreamingUnsupported) {
return err
}
}
resp, err := s.model.Generate(ctx, req)
if err != nil {
return err
}
@@ -489,6 +504,34 @@ func (s *session) ask(ctx context.Context, prompt string) error {
return nil
}
func (s *session) askStream(ctx context.Context, req *ai.Request) error {
stream, err := s.model.Stream(ctx, req)
if err != nil {
return err
}
defer stream.Close()
var reply strings.Builder
for {
chunk, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return err
}
if chunk == nil || chunk.Reply == "" {
continue
}
fmt.Print(chunk.Reply)
reply.WriteString(chunk.Reply)
}
if reply.Len() > 0 {
fmt.Println()
s.hist.Add("assistant", reply.String())
}
return nil
}
// routeToAgent dispatches a message to the right agent.
// If there's only one agent, sends directly. Otherwise uses the
// LLM to classify intent and route.
+91 -13
View File
@@ -14,14 +14,37 @@ import (
"go-micro.dev/v6/store"
)
const noSecretDemoHelp = `No-secret first-agent demo
Use this when you want the fastest provider-free agent success path before
configuring API keys. It runs the maintained support/first-agent transcript with
the deterministic mock model used by CI:
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1
What this proves:
- service tools can be called by an agent
- chat behavior is exercised without contacting a live provider
- run history can be inspected after the prompt
After it passes:
- Build your own service-backed agent: https://go-micro.dev/docs/guides/your-first-agent.html
- Diagnose provider-backed chat: https://go-micro.dev/docs/guides/debugging-agents.html
- Walk the full 0hero lifecycle: https://go-micro.dev/docs/guides/zero-to-hero.html
Use live-provider chat when you are ready for real model behavior:
micro agent preflight # before micro run: prerequisites
micro run
micro chat
micro agent doctor # after micro run: chat/gateway/inspect recovery
micro inspect agent <name>`
func init() {
cmd.Register(&cli.Command{
Name: "runs",
Usage: "Show recorded agent runs",
ArgsUsage: "[agent] [run-id]",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "json", Usage: "Print run data as JSON for automation"},
},
Flags: runFlags(),
Action: func(c *cli.Context) error {
name := c.Args().First()
if name == "" {
@@ -30,14 +53,42 @@ func init() {
if runID := c.Args().Get(1); runID != "" {
return printRunHistory(name, runID, c.Bool("json"))
}
return printRunIndex(name, c.Bool("json"))
return printRunIndex(name, runOptions(c), c.Bool("json"))
},
})
cmd.Register(&cli.Command{
Name: "agent",
Usage: "Manage AI agents",
Usage: "Manage AI agents (try: micro agent demo)",
Subcommands: []*cli.Command{
{
Name: "demo",
Usage: "Show the no-secret first-agent demo command",
Description: `Print the provider-free first-agent path for new developers:
the deterministic mock-model transcript, when to use it, and where to go next
for live-provider chat and inspect/debugging.`,
Action: func(c *cli.Context) error {
fmt.Fprintln(c.App.Writer, noSecretDemoHelp)
return nil
},
},
{
Name: "preflight",
Usage: "Check local prerequisites before the first provider-backed agent",
Action: func(c *cli.Context) error {
return runAgentPreflight(os.Stdout, defaultPreflightDeps())
},
},
{
Name: "doctor",
Usage: "Diagnose chat, gateway, registration, provider, and inspect recovery after micro run",
Flags: []cli.Flag{
&cli.StringFlag{Name: "gateway", Value: "http://localhost:8080", Usage: "Gateway URL started by micro run"},
},
Action: func(c *cli.Context) error {
return runAgentDoctor(os.Stdout, defaultDoctorDeps(), c.String("gateway"))
},
},
{
Name: "list",
Usage: "List registered agents",
@@ -102,9 +153,7 @@ func init() {
Name: "history",
Usage: "Show an agent's stored conversation and run history",
ArgsUsage: "[name] [run-id]",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "json", Usage: "Print run data as JSON for automation"},
},
Flags: runFlags(),
Action: func(c *cli.Context) error {
name := c.Args().First()
if name == "" {
@@ -114,7 +163,7 @@ func init() {
return printRunHistory(name, runID, c.Bool("json"))
}
if c.Bool("json") {
return printRunIndex(name, true)
return printRunIndex(name, runOptions(c), true)
}
// Read from the agent's scoped state store (database
// "agent", table = name) — available whether or not the
@@ -128,15 +177,28 @@ func init() {
fmt.Printf(" \033[2m%s:\033[0m %v\n", m.Role, m.Content)
}
}
return printRunIndex(name, c.Bool("json"))
return printRunIndex(name, runOptions(c), c.Bool("json"))
},
},
},
})
}
func printRunIndex(name string, asJSON bool) error {
runs, err := goagent.ListRunSummaries(store.DefaultStore, name)
func runFlags() []cli.Flag {
return []cli.Flag{
&cli.BoolFlag{Name: "json", Usage: "Print run data as JSON for automation"},
&cli.StringFlag{Name: "status", Usage: "Only show runs with this status (running, done, error, refused)"},
&cli.StringFlag{Name: "trace", Usage: "Only show runs whose trace id matches this full id or prefix"},
&cli.IntFlag{Name: "limit", Usage: "Show the most recently updated N runs"},
}
}
func runOptions(c *cli.Context) goagent.RunListOptions {
return goagent.RunListOptions{Status: c.String("status"), TraceID: c.String("trace"), Limit: c.Int("limit")}
}
func printRunIndex(name string, opts goagent.RunListOptions, asJSON bool) error {
runs, err := goagent.ListRunSummariesWithOptions(store.DefaultStore, name, opts)
if err != nil {
return err
}
@@ -155,7 +217,10 @@ func writeRunIndex(w io.Writer, name string, runs []goagent.RunSummary, asJSON b
}
fmt.Fprintln(w, " Runs:")
for _, run := range runs {
line := fmt.Sprintf(" %s events=%d last=%s updated=%s", run.RunID, run.Events, run.LastKind, run.UpdatedAt.Format("2006-01-02 15:04:05"))
line := fmt.Sprintf(" %s status=%s events=%d duration=%s last=%s updated=%s", run.RunID, run.Status, run.Events, formatDurationMS(run.DurationMS), run.LastKind, run.UpdatedAt.Format("2006-01-02 15:04:05"))
if run.ParentID != "" {
line += " parent=" + run.ParentID
}
if run.TraceID != "" {
line += " trace=" + shortTraceID(run.TraceID)
}
@@ -199,6 +264,9 @@ func writeRunHistory(w io.Writer, name, runID string, events []goagent.RunEvent,
if e.Tokens.TotalTokens > 0 {
line += fmt.Sprintf(" tokens=%d", e.Tokens.TotalTokens)
}
if e.ParentID != "" {
line += " parent=" + e.ParentID
}
if e.TraceID != "" {
line += " trace=" + shortTraceID(e.TraceID)
}
@@ -213,6 +281,16 @@ func writeRunHistory(w io.Writer, name, runID string, events []goagent.RunEvent,
return nil
}
func formatDurationMS(ms int64) string {
if ms <= 0 {
return "0ms"
}
if ms < 1000 {
return fmt.Sprintf("%dms", ms)
}
return fmt.Sprintf("%.1fs", float64(ms)/1000)
}
func shortTraceID(id string) string {
if len(id) <= 12 {
return id
+34 -8
View File
@@ -13,13 +13,15 @@ import (
func TestWriteRunIndexJSON(t *testing.T) {
runs := []goagent.RunSummary{{
RunID: "run-1",
Agent: "runner",
StartedAt: time.Unix(0, 1),
UpdatedAt: time.Unix(0, 2),
Events: 2,
LastKind: "tool",
TraceID: "1234567890abcdef",
RunID: "run-1",
Agent: "runner",
StartedAt: time.Unix(0, 1),
UpdatedAt: time.Unix(0, 2),
DurationMS: 1234,
Events: 2,
Status: "done",
LastKind: "tool",
TraceID: "1234567890abcdef",
}}
var out bytes.Buffer
if err := writeRunIndex(&out, "runner", runs, true); err != nil {
@@ -34,6 +36,29 @@ func TestWriteRunIndexJSON(t *testing.T) {
}
}
func TestWriteRunIndexHumanIncludesStatusAndDuration(t *testing.T) {
runs := []goagent.RunSummary{{
RunID: "run-1",
Agent: "runner",
UpdatedAt: time.Date(2026, 6, 25, 12, 34, 56, 0, time.UTC),
DurationMS: 1234,
Events: 2,
Status: "done",
LastKind: "tool",
ParentID: "parent-run",
}}
var out bytes.Buffer
if err := writeRunIndex(&out, "runner", runs, false); err != nil {
t.Fatal(err)
}
line := out.String()
for _, want := range []string{"run-1", "status=done", "events=2", "duration=1.2s", "last=tool", "parent=parent-run"} {
if !strings.Contains(line, want) {
t.Fatalf("human output %q missing %q", line, want)
}
}
}
func TestWriteRunHistoryHumanAndJSON(t *testing.T) {
events := []goagent.RunEvent{{
Time: time.Date(2026, 6, 25, 12, 34, 56, 7_000_000, time.UTC),
@@ -46,6 +71,7 @@ func TestWriteRunHistoryHumanAndJSON(t *testing.T) {
LatencyMS: 42,
Tokens: ai.Usage{TotalTokens: 5},
TraceID: "1234567890abcdef",
ParentID: "parent-run",
}}
var human bytes.Buffer
@@ -53,7 +79,7 @@ func TestWriteRunHistoryHumanAndJSON(t *testing.T) {
t.Fatal(err)
}
line := human.String()
for _, want := range []string{"12:34:56.007 tool", "probe", "oteltest/unit-model", "42ms", "tokens=5", "trace=1234567890ab"} {
for _, want := range []string{"12:34:56.007 tool", "probe", "oteltest/unit-model", "42ms", "tokens=5", "parent=parent-run", "trace=1234567890ab"} {
if !strings.Contains(line, want) {
t.Fatalf("human output %q missing %q", line, want)
}
+179
View File
@@ -0,0 +1,179 @@
package agent
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
goagent "go-micro.dev/v6/agent"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
type doctorDeps struct {
getenv func(string) string
httpGet func(string) (*http.Response, error)
listServices func() ([]*registry.Service, error)
getService func(string) ([]*registry.Service, error)
listRuns func(string) ([]goagent.RunSummary, error)
}
func defaultDoctorDeps() doctorDeps {
client := &http.Client{Timeout: 2 * time.Second}
return doctorDeps{
getenv: defaultPreflightDeps().getenv,
httpGet: client.Get,
listServices: registry.ListServices,
getService: registry.GetService,
listRuns: func(name string) ([]goagent.RunSummary, error) {
return goagent.ListRunSummariesWithOptions(store.DefaultStore, name, goagent.RunListOptions{Limit: 1})
},
}
}
func runAgentDoctor(w io.Writer, deps doctorDeps, gateway string) error {
if gateway == "" {
gateway = "http://localhost:8080"
}
gateway = strings.TrimRight(gateway, "/")
checks := agentDoctorChecks(deps, gateway)
failures := 0
fmt.Fprintln(w, "First-agent recovery doctor")
for _, check := range checks {
mark := "✓"
if !check.OK {
mark = "✗"
failures++
}
fmt.Fprintf(w, " %s %s — %s\n", mark, check.Name, check.Detail)
if !check.OK && check.Fix != "" {
fmt.Fprintf(w, " Fix: %s\n", check.Fix)
}
if !check.OK && check.Next != "" {
fmt.Fprintf(w, " Next: %s\n", check.Next)
}
}
if failures > 0 {
return fmt.Errorf("first-agent doctor found %d recovery boundary issue(s)", failures)
}
fmt.Fprintln(w, "\nReady: gateway, agent registration, chat settings, and inspect history are reachable.")
return nil
}
func agentDoctorChecks(deps doctorDeps, gateway string) []preflightCheck {
if deps.getenv == nil {
deps.getenv = defaultPreflightDeps().getenv
}
if deps.httpGet == nil {
deps.httpGet = http.Get
}
if deps.listServices == nil {
deps.listServices = registry.ListServices
}
if deps.getService == nil {
deps.getService = registry.GetService
}
if deps.listRuns == nil {
deps.listRuns = func(name string) ([]goagent.RunSummary, error) {
return goagent.ListRunSummariesWithOptions(store.DefaultStore, name, goagent.RunListOptions{Limit: 1})
}
}
checks := []preflightCheck{checkGateway(deps, gateway), checkChatSettings(deps, gateway)}
agents, regCheck := checkAgentRegistration(deps)
checks = append(checks, regCheck)
checks = append(checks, checkRunHistory(deps, agents))
checks = append(checks, checkProviderConfig(deps))
return checks
}
func checkGateway(deps doctorDeps, gateway string) preflightCheck {
resp, err := deps.httpGet(gateway + "/agent")
if err != nil {
return preflightCheck{Name: "gateway /agent", Detail: err.Error(), Fix: "Start the local gateway with `micro run`, or pass the matching URL with `micro agent doctor --gateway http://localhost:<port>`.", Next: "Then open " + gateway + "/agent or retry `micro chat`."}
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return preflightCheck{Name: "gateway /agent", Detail: fmt.Sprintf("%s returned %s", gateway+"/agent", resp.Status), Fix: "Confirm `micro run` is serving the web gateway and that auth/proxy settings are not blocking /agent.", Next: "See docs/guides/debugging-agents.html#chat-and-gateway-failures."}
}
return preflightCheck{Name: "gateway /agent", OK: true, Detail: gateway + "/agent is reachable"}
}
func checkChatSettings(deps doctorDeps, gateway string) preflightCheck {
resp, err := deps.httpGet(gateway + "/api/agent/settings")
if err != nil {
return preflightCheck{Name: "chat settings endpoint", Detail: err.Error(), Fix: "Keep `micro run` running and retry; the playground uses /api/agent/settings before chat prompts.", Next: "See docs/guides/debugging-agents.html#chat-and-gateway-failures."}
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return preflightCheck{Name: "chat settings endpoint", Detail: fmt.Sprintf("returned %s", resp.Status), Fix: "Check gateway auth/proxy configuration or use the Agent settings page to confirm chat settings load.", Next: "See docs/guides/debugging-agents.html#provider-failures."}
}
var settings map[string]string
_ = json.NewDecoder(resp.Body).Decode(&settings)
if settings["provider"] != "" || settings["model"] != "" || settings["api_key"] != "" {
return preflightCheck{Name: "chat settings endpoint", OK: true, Detail: "reachable with saved provider settings"}
}
return preflightCheck{Name: "chat settings endpoint", OK: true, Detail: "reachable; no saved provider settings"}
}
func checkAgentRegistration(deps doctorDeps) ([]string, preflightCheck) {
services, err := deps.listServices()
if err != nil {
return nil, preflightCheck{Name: "agent registration", Detail: err.Error(), Fix: "Keep the scaffolded agent process running under `micro run` and retry `micro agent list`.", Next: "See docs/guides/your-first-agent.html#run-your-agent."}
}
var agents []string
for _, svc := range services {
records, err := deps.getService(svc.Name)
if err != nil || len(records) == 0 {
continue
}
if serviceIsAgent(records[0]) {
agents = append(agents, svc.Name)
}
}
if len(agents) == 0 {
return nil, preflightCheck{Name: "agent registration", Detail: "no registered agent services found", Fix: "Start an agent project with `micro run` and confirm `micro agent list` shows it.", Next: "Use docs/guides/no-secret-first-agent.html for a deterministic no-provider agent."}
}
return agents, preflightCheck{Name: "agent registration", OK: true, Detail: "found " + strings.Join(agents, ", ")}
}
func serviceIsAgent(svc *registry.Service) bool {
if svc.Metadata != nil && svc.Metadata["type"] == "agent" {
return true
}
for _, node := range svc.Nodes {
if node.Metadata != nil && node.Metadata["type"] == "agent" {
return true
}
}
return false
}
func checkRunHistory(deps doctorDeps, agents []string) preflightCheck {
if len(agents) == 0 {
return preflightCheck{Name: "inspect run history", Detail: "skipped because no agent is registered", Fix: "Fix agent registration first, then chat once and run `micro inspect agent <name>`.", Next: "See docs/guides/debugging-agents.html#inspect-run-history."}
}
for _, name := range agents {
runs, err := deps.listRuns(name)
if err != nil {
return preflightCheck{Name: "inspect run history", Detail: err.Error(), Fix: "Ensure the local store is writable and retry `micro inspect agent " + name + "`.", Next: "See docs/guides/debugging-agents.html#inspect-run-history."}
}
if len(runs) > 0 {
return preflightCheck{Name: "inspect run history", OK: true, Detail: "recent runs available for " + name}
}
}
return preflightCheck{Name: "inspect run history", Detail: "no recorded agent runs yet", Fix: "Send one prompt with `micro chat` or the /agent playground, then run `micro inspect agent " + agents[0] + "`.", Next: "See docs/guides/your-first-agent.html#inspect-what-happened."}
}
func checkProviderConfig(deps doctorDeps) preflightCheck {
check := checkProviderKey(preflightDeps{getenv: deps.getenv})
check.Name = "provider configuration"
if !check.OK {
check.Detail = "no provider key found for live LLM chat"
check.Fix = "For provider-backed chat, export MICRO_AI_API_KEY or a provider-specific key; for no-secret recovery, use the mock-model walkthrough."
}
return check
}
+75
View File
@@ -0,0 +1,75 @@
package agent
import (
"bytes"
"errors"
"io"
"net/http"
"strings"
"testing"
goagent "go-micro.dev/v6/agent"
"go-micro.dev/v6/registry"
)
func doctorHTTP(status int, body string) func(string) (*http.Response, error) {
return func(string) (*http.Response, error) {
return &http.Response{StatusCode: status, Status: "200 OK", Body: io.NopCloser(strings.NewReader(body))}, nil
}
}
func TestRunAgentDoctorPassesWhenRecoveryBoundariesReachable(t *testing.T) {
deps := doctorDeps{
getenv: func(key string) string {
if key == "MICRO_AI_API_KEY" {
return "set"
}
return ""
},
httpGet: doctorHTTP(200, `{"provider":"anthropic","model":"claude"}`),
listServices: func() ([]*registry.Service, error) {
return []*registry.Service{{Name: "assistant"}}, nil
},
getService: func(name string) ([]*registry.Service, error) {
return []*registry.Service{{Name: name, Metadata: map[string]string{"type": "agent"}}}, nil
},
listRuns: func(name string) ([]goagent.RunSummary, error) {
return []goagent.RunSummary{{RunID: "run-1", Status: "done"}}, nil
},
}
var out bytes.Buffer
if err := runAgentDoctor(&out, deps, "http://example.test"); err != nil {
t.Fatalf("runAgentDoctor() error = %v\n%s", err, out.String())
}
got := out.String()
for _, want := range []string{"First-agent recovery doctor", "✓ gateway /agent", "✓ chat settings endpoint", "✓ agent registration", "✓ inspect run history", "✓ provider configuration", "Ready:"} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestRunAgentDoctorReportsActionableRecoveryFailures(t *testing.T) {
deps := doctorDeps{
getenv: func(string) string { return "" },
httpGet: func(string) (*http.Response, error) { return nil, errors.New("connection refused") },
listServices: func() ([]*registry.Service, error) {
return []*registry.Service{{Name: "greeter"}}, nil
},
getService: func(name string) ([]*registry.Service, error) {
return []*registry.Service{{Name: name}}, nil
},
listRuns: func(name string) ([]goagent.RunSummary, error) { return nil, nil },
}
var out bytes.Buffer
err := runAgentDoctor(&out, deps, "http://localhost:8080")
if err == nil {
t.Fatal("runAgentDoctor() error = nil")
}
got := out.String()
for _, want := range []string{"✗ gateway /agent", "micro run", "✗ chat settings endpoint", "✗ agent registration", "micro agent list", "✗ inspect run history", "micro inspect agent <name>", "✗ provider configuration", "docs/guides/no-secret-first-agent.html"} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
+163
View File
@@ -0,0 +1,163 @@
package agent
import (
"fmt"
"io"
"net"
"os"
"os/exec"
"strings"
"go-micro.dev/v6/cmd"
)
type preflightCheck struct {
Name string
OK bool
Detail string
Fix string
Next string
}
type preflightDeps struct {
lookPath func(string) (string, error)
commandOutput func(string, ...string) ([]byte, error)
executable func() (string, error)
version func() string
getenv func(string) string
listen func(string, string) (net.Listener, error)
}
func defaultPreflightDeps() preflightDeps {
return preflightDeps{
lookPath: exec.LookPath,
commandOutput: func(name string, args ...string) ([]byte, error) { return exec.Command(name, args...).CombinedOutput() },
executable: os.Executable,
version: func() string { return cmd.App().Version },
getenv: os.Getenv,
listen: net.Listen,
}
}
func runAgentPreflight(w io.Writer, deps preflightDeps) error {
checks := agentPreflightChecks(deps)
failures := 0
fmt.Fprintln(w, "First-agent preflight")
for _, check := range checks {
mark := "✓"
if !check.OK {
mark = "✗"
failures++
}
fmt.Fprintf(w, " %s %s — %s\n", mark, check.Name, check.Detail)
if !check.OK && check.Fix != "" {
fmt.Fprintf(w, " Fix: %s\n", check.Fix)
}
if !check.OK && check.Next != "" {
fmt.Fprintf(w, " Next: %s\n", check.Next)
}
}
if failures > 0 {
return fmt.Errorf("first-agent preflight failed: %d check(s) need attention", failures)
}
fmt.Fprintln(w, "\nReady for the first-agent walkthrough: micro run, then open http://localhost:8080/agent or use micro chat.")
return nil
}
func agentPreflightChecks(deps preflightDeps) []preflightCheck {
if deps.lookPath == nil {
deps.lookPath = exec.LookPath
}
if deps.commandOutput == nil {
deps.commandOutput = func(name string, args ...string) ([]byte, error) { return exec.Command(name, args...).CombinedOutput() }
}
if deps.executable == nil {
deps.executable = os.Executable
}
if deps.version == nil {
deps.version = func() string { return cmd.App().Version }
}
if deps.getenv == nil {
deps.getenv = os.Getenv
}
if deps.listen == nil {
deps.listen = net.Listen
}
checks := []preflightCheck{checkGoToolchain(deps), checkMicroBinary(deps), checkProviderKey(deps), checkPortAvailable(deps, ":8080", "micro run gateway and /agent playground")}
return checks
}
func checkGoToolchain(deps preflightDeps) preflightCheck {
path, err := deps.lookPath("go")
if err != nil {
return preflightCheck{Name: "Go toolchain", Detail: "go was not found on PATH", Fix: "Install Go 1.24 or newer from https://go.dev/doc/install and ensure go is on PATH.", Next: "After installing Go, rerun micro agent preflight, then continue with docs/guides/your-first-agent.html."}
}
out, err := deps.commandOutput("go", "version")
if err != nil {
return preflightCheck{Name: "Go toolchain", Detail: strings.TrimSpace(string(out)), Fix: "Ensure the go command runs successfully (try `go version`) before starting the agent walkthrough.", Next: "Use docs/guides/debugging-agents.html after the toolchain check passes if an agent run still fails."}
}
version := firstLine(out)
if !goVersionAtLeast(version, 1, 24) {
return preflightCheck{Name: "Go toolchain", Detail: fmt.Sprintf("%s (%s)", version, path), Fix: "Upgrade to Go 1.24 or newer before running generated services.", Next: "Rerun micro agent preflight, then continue with docs/guides/your-first-agent.html."}
}
return preflightCheck{Name: "Go toolchain", OK: true, Detail: fmt.Sprintf("%s (%s)", version, path)}
}
func checkMicroBinary(deps preflightDeps) preflightCheck {
exe, err := deps.executable()
if err != nil || exe == "" {
return preflightCheck{Name: "micro binary", Detail: "micro executable path is unavailable", Fix: "Install the micro CLI or run this check through `go run ./cmd/micro agent preflight` from the repository.", Next: "Then follow docs/getting-started.html for the scaffold -> run path."}
}
version := deps.version()
if version == "" {
version = "version unavailable"
}
return preflightCheck{Name: "micro binary", OK: true, Detail: fmt.Sprintf("%s (%s)", version, exe)}
}
func checkProviderKey(deps preflightDeps) preflightCheck {
keys := []string{"MICRO_AI_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GROQ_API_KEY", "MISTRAL_API_KEY", "TOGETHER_API_KEY", "ATLASCLOUD_API_KEY"}
var found []string
for _, k := range keys {
if deps.getenv(k) != "" {
found = append(found, k)
}
}
if len(found) == 0 {
return preflightCheck{Name: "provider API key", Detail: "no supported provider key found", Fix: "Export MICRO_AI_API_KEY or a provider key such as ANTHROPIC_API_KEY before running provider-backed agents.", Next: "For a no-secret path, run the mock-model walkthrough in docs/guides/no-secret-first-agent.html; for real providers, see docs/guides/debugging-agents.html#provider-failures."}
}
return preflightCheck{Name: "provider API key", OK: true, Detail: "found " + strings.Join(found, ", ")}
}
func checkPortAvailable(deps preflightDeps, addr, use string) preflightCheck {
ln, err := deps.listen("tcp", addr)
if err != nil {
return preflightCheck{Name: "local port " + addr, Detail: "busy or unavailable for " + use, Fix: "Stop the process using " + addr + " (for example, `lsof -i :8080`) or run `micro run --address` with a free port.", Next: "Once the gateway starts, open http://localhost:8080/agent or continue with docs/guides/your-first-agent.html#chat-with-your-agent."}
}
_ = ln.Close()
return preflightCheck{Name: "local port " + addr, OK: true, Detail: "available for " + use}
}
func firstLine(b []byte) string {
s := strings.TrimSpace(string(b))
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
func goVersionAtLeast(line string, wantMajor, wantMinor int) bool {
idx := strings.Index(line, "go1.")
if idx < 0 {
return false
}
var major, minor int
if _, err := fmt.Sscanf(line[idx:], "go%d.%d", &major, &minor); err != nil {
return false
}
if major != wantMajor {
return major > wantMajor
}
return minor >= wantMinor
}
+124
View File
@@ -0,0 +1,124 @@
package agent
import (
"bytes"
"errors"
"net"
"strings"
"testing"
)
type stubListener struct{}
func (stubListener) Accept() (net.Conn, error) { return nil, errors.New("closed") }
func (stubListener) Close() error { return nil }
func (stubListener) Addr() net.Addr { return stubAddr(":8080") }
type stubAddr string
func (a stubAddr) Network() string { return "tcp" }
func (a stubAddr) String() string { return string(a) }
func TestRunAgentPreflightPassesWithKeyAndFreePort(t *testing.T) {
deps := preflightDeps{
lookPath: func(name string) (string, error) { return "/usr/bin/" + name, nil },
commandOutput: func(name string, args ...string) ([]byte, error) {
return []byte("go version go1.24.0 linux/amd64\n"), nil
},
executable: func() (string, error) { return "/usr/local/bin/micro", nil },
getenv: func(key string) string {
if key == "ANTHROPIC_API_KEY" {
return "set"
}
return ""
},
listen: func(network, address string) (net.Listener, error) { return stubListener{}, nil },
}
var out bytes.Buffer
if err := runAgentPreflight(&out, deps); err != nil {
t.Fatalf("runAgentPreflight() error = %v", err)
}
got := out.String()
for _, want := range []string{"First-agent preflight", "✓ Go toolchain", "✓ micro binary", "✓ provider API key", "✓ local port :8080", "Ready for the first-agent walkthrough"} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestRunAgentPreflightReportsActionableFailures(t *testing.T) {
deps := preflightDeps{
lookPath: func(name string) (string, error) { return "", errors.New("not found") },
executable: func() (string, error) { return "", errors.New("unknown") },
getenv: func(key string) string { return "" },
listen: func(network, address string) (net.Listener, error) { return nil, errors.New("in use") },
}
var out bytes.Buffer
err := runAgentPreflight(&out, deps)
if err == nil {
t.Fatal("runAgentPreflight() error = nil")
}
got := out.String()
for _, want := range []string{"✗ Go toolchain", "go was not found on PATH", "https://go.dev/doc/install", "docs/guides/your-first-agent.html", "✗ micro binary", "go run ./cmd/micro agent preflight", "✗ provider API key", "docs/guides/no-secret-first-agent.html", "docs/guides/debugging-agents.html#provider-failures", "✗ local port :8080", "lsof -i :8080", "micro run --address"} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestRunAgentPreflightReportsOldGoVersion(t *testing.T) {
deps := preflightDeps{
lookPath: func(name string) (string, error) { return "/usr/bin/" + name, nil },
commandOutput: func(name string, args ...string) ([]byte, error) {
return []byte("go version go1.23.9 linux/amd64\n"), nil
},
executable: func() (string, error) { return "/usr/local/bin/micro", nil },
getenv: func(key string) string {
if key == "ANTHROPIC_API_KEY" {
return "set"
}
return ""
},
listen: func(network, address string) (net.Listener, error) { return stubListener{}, nil },
}
var out bytes.Buffer
err := runAgentPreflight(&out, deps)
if err == nil {
t.Fatal("runAgentPreflight() error = nil")
}
got := out.String()
for _, want := range []string{"✗ Go toolchain", "go1.23.9", "Upgrade to Go 1.24 or newer", "Rerun micro agent preflight"} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestGoVersionAtLeast(t *testing.T) {
tests := []struct {
line string
want bool
}{
{line: "go version go1.24.0 linux/amd64", want: true},
{line: "go version go1.25.1 linux/amd64", want: true},
{line: "go version go1.23.9 linux/amd64", want: false},
{line: "unexpected", want: false},
}
for _, tt := range tests {
if got := goVersionAtLeast(tt.line, 1, 24); got != tt.want {
t.Fatalf("goVersionAtLeast(%q) = %v, want %v", tt.line, got, tt.want)
}
}
}
func TestFirstLine(t *testing.T) {
if got := firstLine([]byte("one\ntwo")); got != "one" {
t.Fatalf("firstLine() = %q", got)
}
if got := firstLine([]byte(" single ")); got != "single" {
t.Fatalf("firstLine() = %q", got)
}
}
+114
View File
@@ -24,6 +24,87 @@ import (
_ "go-micro.dev/v6/cmd/micro/cli/remote"
)
const zeroToHeroHelp = `0hero no-secret lifecycle demo
Run this from a go-micro repository checkout when you want one command that
proves the maintained services agents workflows path without provider keys:
./internal/harness/zero-to-hero-ci/run.sh
That script runs the same deterministic path CI uses:
- CLI discovery for scaffold, run, chat, inspect, flow runs, and deploy dry-run
- the smallest first-agent example
- the support-desk reference app with services, an agent, a flow, and an approval gate
- plan/delegate and universe harnesses with only the model mocked
If you only want the runnable examples first:
go run ./examples/first-agent
go run ./examples/support
Full local contract:
make harness
Guide: https://go-micro.dev/docs/guides/zero-to-hero.html`
const examplesWayfinding = `First-agent examples (no provider key required)
Run these from a go-micro repository checkout in this order:
1. Smallest service-backed agent
go run ./examples/first-agent
Proves an agent can call a service tool with the deterministic mock model.
2. No-secret support-agent transcript
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1
Exercises service tools, mock-model chat, and inspectable run history.
3. Full services agents workflows reference app
go run ./examples/support
Shows the support desk service, agent, workflow, and approval gate together.
Then continue the same path with the installed CLI:
micro agent demo
micro docs
micro zero-to-hero
Guides:
https://go-micro.dev/docs/guides/no-secret-first-agent.html
https://go-micro.dev/docs/guides/your-first-agent.html
https://go-micro.dev/docs/guides/debugging-agents.html
https://go-micro.dev/docs/guides/zero-to-hero.html`
const docsWayfinding = `First-agent and 0hero docs:
1. Start with the no-secret CLI demo
micro agent demo
This prints the maintained support-agent transcript command so you can
prove service tools, mock-model chat, and inspectable run history without
configuring a provider key.
2. No-secret first-agent transcript
https://go-micro.dev/docs/guides/no-secret-first-agent.html
Run the maintained support agent without a provider key:
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1
3. Your First Agent
https://go-micro.dev/docs/guides/your-first-agent.html
Build a service-backed agent, then use:
micro agent preflight # before micro run: prerequisites
micro run
micro chat
micro agent doctor # after micro run: chat/gateway/inspect recovery
4. Debugging your agent
https://go-micro.dev/docs/guides/debugging-agents.html
Inspect agent runs and memory with:
micro agent doctor
micro inspect agent <name>
micro agent history <name>
5. 0hero Reference
https://go-micro.dev/docs/guides/zero-to-hero.html
Walk the scaffold run chat inspect deploy dry-run lifecycle.`
func genProtoHandler(c *cli.Context) error {
cmd := exec.Command("find", ".", "-name", "*.proto", "-exec", "protoc", "--proto_path=.", "--micro_out=.", "--go_out=.", `{}`, `;`)
cmd.Stdout = os.Stdout
@@ -96,6 +177,39 @@ func init() {
return nil
},
},
{
Name: "examples",
Usage: "Show provider-free first-agent example paths",
Description: `Print the maintained no-secret examples for the services agents
workflows on-ramp: first-agent, transcript, support app, and matching guides.`,
Action: func(ctx *cli.Context) error {
fmt.Fprintln(ctx.App.Writer, examplesWayfinding)
return nil
},
},
{
Name: "zero-to-hero",
Usage: "Show the no-secret 0→hero lifecycle demo command",
Description: `Print the maintained provider-free services agents workflows
lifecycle command and the smaller runnable examples it covers.`,
Aliases: []string{"hero"},
Action: func(ctx *cli.Context) error {
fmt.Fprintln(ctx.App.Writer, zeroToHeroHelp)
return nil
},
},
{
Name: "docs",
Usage: "Show the first-agent and 0→hero documentation path",
Description: `Print the maintained adoption on-ramp for new Go Micro developers:
the no-secret first-agent transcript, Your First Agent, debugging guide, and
0hero lifecycle reference.`,
Action: func(ctx *cli.Context) error {
fmt.Fprintln(ctx.App.Writer, docsWayfinding)
return nil
},
},
{
Name: "call",
Usage: "Call a service",
+102 -38
View File
@@ -42,14 +42,31 @@ func Deploy(c *cli.Context) error {
return showDeployHelp()
}
target, remotePath := resolveDeployTarget(c, target, cfg)
if c.Bool("dry-run") {
return printDeployPlan(c, target, cfg, remotePath)
}
return deploySSH(c, target, cfg, remotePath)
}
func resolveDeployTarget(c *cli.Context, target string, cfg *config.Config) (string, string) {
remotePath := c.String("path")
if remotePath == "" {
remotePath = defaultRemotePath
}
// Check if target is a named target from config
if cfg != nil {
if dt, ok := cfg.Deploy[target]; ok {
target = dt.SSH
if dt.Path != "" && !c.IsSet("path") {
remotePath = dt.Path
}
}
}
return deploySSH(c, target, cfg)
return target, remotePath
}
func showDeployHelp() error {
@@ -82,7 +99,82 @@ func showDeployTargets(cfg *config.Config) error {
return fmt.Errorf("%s", sb.String())
}
func deploySSH(c *cli.Context, target string, cfg *config.Config) error {
func printDeployPlan(c *cli.Context, target string, cfg *config.Config, remotePath string) error {
dir := c.Args().Get(1)
if dir == "" {
dir = "."
}
absDir, err := filepath.Abs(dir)
if err != nil {
return fmt.Errorf("failed to get absolute path: %w", err)
}
if cfg == nil {
cfg, _ = config.Load(absDir)
}
if remotePath == "" {
remotePath = defaultRemotePath
}
services, err := deployServices(absDir, cfg, c.String("service"))
if err != nil {
return err
}
fmt.Println()
fmt.Println(" \033[1mmicro deploy --dry-run\033[0m")
fmt.Println()
fmt.Printf(" Target \033[36m%s\033[0m\n", target)
fmt.Printf(" Remote path %s\n", remotePath)
fmt.Printf(" Services %s\n", strings.Join(services, ", "))
fmt.Println()
fmt.Println(" Plan:")
fmt.Println(" 1. Build linux/amd64 service binaries")
fmt.Printf(" 2. Copy binaries to %s/bin/\n", remotePath)
fmt.Println(" 3. Enable and restart micro@<service> systemd units")
fmt.Println(" 4. Check service health")
fmt.Println()
fmt.Println(" No SSH, rsync, systemd, or remote deployment was performed.")
return nil
}
func deployServices(absDir string, cfg *config.Config, filterService string) ([]string, error) {
if filterService != "" && cfg != nil {
found := false
for _, svc := range cfg.Services {
if svc.Name == filterService {
found = true
break
}
}
if !found && len(cfg.Services) > 0 {
return nil, fmt.Errorf("service '%s' not found in configuration", filterService)
}
}
if cfg != nil && len(cfg.Services) > 0 {
sorted, err := cfg.TopologicalSort()
if err != nil {
return nil, err
}
services := make([]string, 0, len(sorted))
for _, svc := range sorted {
if filterService == "" || svc.Name == filterService {
services = append(services, svc.Name)
}
}
return services, nil
}
services := []string{filepath.Base(absDir)}
if filterService != "" && filterService != services[0] {
return nil, fmt.Errorf("service '%s' not found (only '%s' available)", filterService, services[0])
}
return services, nil
}
func deploySSH(c *cli.Context, target string, cfg *config.Config, remotePath string) error {
dir := c.Args().Get(1)
if dir == "" {
dir = "."
@@ -98,7 +190,6 @@ func deploySSH(c *cli.Context, target string, cfg *config.Config) error {
cfg, _ = config.Load(absDir)
}
remotePath := c.String("path")
if remotePath == "" {
remotePath = defaultRemotePath
}
@@ -108,19 +199,10 @@ func deploySSH(c *cli.Context, target string, cfg *config.Config) error {
fmt.Println()
fmt.Printf(" Target \033[36m%s\033[0m\n\n", target)
// Early validation: Check if the requested service exists before SSH checks
filterService := c.String("service")
if filterService != "" && cfg != nil {
found := false
for _, svc := range cfg.Services {
if svc.Name == filterService {
found = true
break
}
}
if !found && len(cfg.Services) > 0 {
return fmt.Errorf("service '%s' not found in configuration", filterService)
}
// Early validation: resolve services before SSH checks.
services, err := deployServices(absDir, cfg, c.String("service"))
if err != nil {
return err
}
// Step 1: Check SSH connectivity
@@ -140,28 +222,6 @@ func deploySSH(c *cli.Context, target string, cfg *config.Config) error {
fmt.Println("\u2713")
// Step 3: Build binaries
var services []string
if cfg != nil && len(cfg.Services) > 0 {
sorted, err := cfg.TopologicalSort()
if err != nil {
return err
}
for _, svc := range sorted {
// If --service flag is provided, only include that service
if filterService == "" || svc.Name == filterService {
services = append(services, svc.Name)
}
}
} else {
// Single service project
services = []string{filepath.Base(absDir)}
// If --service flag was provided for a single-service project, validate it matches
if filterService != "" && filterService != services[0] {
return fmt.Errorf("service '%s' not found (only '%s' available)", filterService, services[0])
}
}
fmt.Printf(" Building binaries... ")
if err := buildBinaries(absDir, cfg, c.Bool("build"), services); err != nil {
fmt.Println("\u2717")
@@ -480,6 +540,10 @@ The deploy process:
Name: "service",
Usage: "Deploy only a specific service (for multi-service projects)",
},
&cli.BoolFlag{
Name: "dry-run",
Usage: "Print the deployment plan without building, connecting, copying, or restarting services",
},
},
})
}
+167
View File
@@ -0,0 +1,167 @@
package deploy
import (
"flag"
"os"
"strings"
"testing"
"github.com/urfave/cli/v2"
"go-micro.dev/v6/cmd/micro/run/config"
)
func newDeployTestContext(t *testing.T, args ...string) *cli.Context {
t.Helper()
set := flag.NewFlagSet("deploy", flag.ContinueOnError)
set.String("path", defaultRemotePath, "")
set.String("ssh", "", "")
set.String("service", "", "")
set.Bool("build", false, "")
set.Bool("dry-run", false, "")
if err := set.Parse(args); err != nil {
t.Fatalf("parse flags: %v", err)
}
return cli.NewContext(cli.NewApp(), set, nil)
}
func TestDeployNoTargetExplainsInitAndDeployHandoff(t *testing.T) {
err := showDeployHelp()
if err == nil {
t.Fatal("expected missing target guidance")
}
msg := err.Error()
for _, want := range []string{
"no deployment target specified",
"sudo micro init --server",
"micro deploy user@your-server",
"deploy prod",
} {
if !strings.Contains(msg, want) {
t.Fatalf("missing %q in guidance:\n%s", want, msg)
}
}
}
func TestDeployListsConfiguredTargetsWhenNoTargetProvided(t *testing.T) {
err := showDeployTargets(&config.Config{Deploy: map[string]*config.DeployTarget{
"prod": {Name: "prod", SSH: "deploy@prod.example.com"},
"staging": {Name: "staging", SSH: "deploy@staging.example.com"},
}})
if err == nil {
t.Fatal("expected configured target guidance")
}
msg := err.Error()
for _, want := range []string{
"Available deploy targets:",
"prod -> deploy@prod.example.com",
"staging -> deploy@staging.example.com",
"micro deploy <target>",
} {
if !strings.Contains(msg, want) {
t.Fatalf("missing %q in configured target guidance:\n%s", want, msg)
}
}
}
func TestResolveDeployTargetUsesConfigTargetAndPath(t *testing.T) {
ctx := newDeployTestContext(t, "prod")
cfg := &config.Config{Deploy: map[string]*config.DeployTarget{
"prod": {Name: "prod", SSH: "deploy@prod.example.com", Path: "/srv/micro"},
}}
target, remotePath := resolveDeployTarget(ctx, ctx.Args().First(), cfg)
if target != "deploy@prod.example.com" {
t.Fatalf("target = %q, want configured SSH", target)
}
if remotePath != "/srv/micro" {
t.Fatalf("remotePath = %q, want configured path", remotePath)
}
}
func TestResolveDeployTargetAllowsCLIPathOverride(t *testing.T) {
ctx := newDeployTestContext(t, "--path", "/tmp/micro", "prod")
cfg := &config.Config{Deploy: map[string]*config.DeployTarget{
"prod": {Name: "prod", SSH: "deploy@prod.example.com", Path: "/srv/micro"},
}}
target, remotePath := resolveDeployTarget(ctx, ctx.Args().First(), cfg)
if target != "deploy@prod.example.com" {
t.Fatalf("target = %q, want configured SSH", target)
}
if remotePath != "/tmp/micro" {
t.Fatalf("remotePath = %q, want CLI override", remotePath)
}
}
func TestDeployConfigParserSupportsDeployTargets(t *testing.T) {
dir := t.TempDir()
path := dir + "/micro.mu"
content := `service api
path ./api
deploy prod
ssh deploy@prod.example.com
path /srv/micro
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := config.ParseMu(path)
if err != nil {
t.Fatalf("parse config: %v", err)
}
prod := cfg.Deploy["prod"]
if prod == nil {
t.Fatal("missing prod deploy target")
}
if prod.SSH != "deploy@prod.example.com" || prod.Path != "/srv/micro" {
t.Fatalf("deploy target = %#v", prod)
}
}
func TestDeployDryRunPlansConfiguredTargetWithoutRemoteSideEffects(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(dir+"/micro.mu", []byte(`service api
path ./api
deploy prod
ssh deploy@prod.example.com
path /srv/micro
`), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
oldwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() {
if err := os.Chdir(oldwd); err != nil {
t.Errorf("restore cwd: %v", err)
}
})
ctx := newDeployTestContext(t, "--dry-run", "prod")
if err := Deploy(ctx); err != nil {
t.Fatalf("dry-run deploy: %v", err)
}
}
func TestDeployDryRunValidatesRequestedService(t *testing.T) {
ctx := newDeployTestContext(t, "--dry-run", "--service", "missing", "prod")
cfg := &config.Config{Services: map[string]*config.Service{
"api": {Name: "api", Path: "./api"},
}}
err := printDeployPlan(ctx, "deploy@prod.example.com", cfg, defaultRemotePath)
if err == nil {
t.Fatal("expected dry-run to validate service names")
}
if !strings.Contains(err.Error(), "service 'missing' not found in configuration") {
t.Fatalf("unexpected error: %v", err)
}
}
+178 -13
View File
@@ -1,24 +1,109 @@
package new
import (
"bytes"
"errors"
"flag"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/urfave/cli/v2"
)
// TestZeroToOneContract locks the documented getting-started path:
// `micro new helloworld` must produce an ordinary Go service that the Go
// toolchain can build. The generated module is pointed back at this checkout
// so the contract stays local and deterministic in CI.
// toolchain can build, run long enough to start, and call through its generated
// handler. The generated module is pointed back at this checkout so the
// contract stays local and deterministic in CI.
//
// It shells out to `micro new` (which runs `go mod tidy`) and `go build`, so
// it needs the Go toolchain and module access; it is skipped under `-short`.
func TestZeroToOneContract(t *testing.T) {
generated := generateService(t, "helloworld")
for _, rel := range []string{"go.mod", "main.go", "handler/helloworld.go", "README.md", "Makefile"} {
if _, err := os.Stat(filepath.Join(generated.dir, rel)); err != nil {
t.Fatalf("generated file %s: %v", rel, err)
}
}
generated.replaceModule(t)
generated.build(t)
generated.run(t)
generated.call(t, "Alice", "Hello Alice")
}
// TestZeroToOneNoMCPContract keeps the MCP opt-out path honest. Some services
// intentionally run without the local MCP listener, but that variant must still
// satisfy the same 0→1 contract: scaffold, tidy, and build without additional
// toolchain dependencies.
func TestZeroToOneNoMCPContract(t *testing.T) {
generated := generateService(t, "worker", "--no-mcp")
main, err := os.ReadFile(filepath.Join(generated.dir, "main.go"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(main), "gateway/mcp") || strings.Contains(string(main), "WithMCP") {
t.Fatalf("--no-mcp generated main.go with MCP wiring:\n%s", main)
}
generated.replaceModule(t)
generated.build(t)
generated.run(t)
generated.call(t, "Bob", "Hello Bob")
}
func TestPrintNextStepsSurfacesFirstAgentPath(t *testing.T) {
var out bytes.Buffer
printNextSteps(&out, "helloworld", false)
for _, want := range []string{
"cd helloworld",
"micro agent preflight",
"go run .",
"micro chat",
"micro inspect agent <name>",
"micro agent demo",
"micro docs",
"your-first-agent.html",
"zero-to-hero.html",
"http://localhost:3001/mcp/tools",
} {
if !strings.Contains(out.String(), want) {
t.Fatalf("next steps missing %q:\n%s", want, out.String())
}
}
}
func TestPrintNextStepsNoMCPSkipsMCPHints(t *testing.T) {
var out bytes.Buffer
printNextSteps(&out, "worker", true)
for _, want := range []string{"micro agent preflight", "micro chat", "micro inspect agent <name>", "micro agent demo", "micro docs"} {
if !strings.Contains(out.String(), want) {
t.Fatalf("--no-mcp next steps missing %q:\n%s", want, out.String())
}
}
for _, notWant := range []string{"http://localhost:3001/mcp/tools", "micro mcp serve"} {
if strings.Contains(out.String(), notWant) {
t.Fatalf("--no-mcp next steps should not include %q:\n%s", notWant, out.String())
}
}
}
type generatedService struct {
dir string
repoRoot string
}
func generateService(t *testing.T, name string, args ...string) generatedService {
t.Helper()
if testing.Short() {
t.Skip("contract test shells out to the Go toolchain; skipped with -short")
}
@@ -39,37 +124,117 @@ func TestZeroToOneContract(t *testing.T) {
defer os.Chdir(oldwd)
set := flag.NewFlagSet("micro-new", flag.ContinueOnError)
if err := set.Parse([]string{"helloworld"}); err != nil {
set.Bool("no-mcp", false, "")
set.Bool("proto", false, "")
set.String("template", "", "")
set.String("prompt", "", "")
set.String("provider", "", "")
set.String("api_key", "", "")
if err := set.Parse(append(args, name)); err != nil {
t.Fatal(err)
}
ctx := cli.NewContext(cli.NewApp(), set, nil)
if err := Run(ctx); err != nil {
t.Fatalf("micro new helloworld: %v", err)
t.Fatalf("micro new %s %s: %v", strings.Join(args, " "), name, err)
}
serviceDir := filepath.Join(tmp, "helloworld")
for _, rel := range []string{"go.mod", "main.go", "handler/helloworld.go", "README.md", "Makefile"} {
if _, err := os.Stat(filepath.Join(serviceDir, rel)); err != nil {
t.Fatalf("generated file %s: %v", rel, err)
}
}
return generatedService{dir: filepath.Join(tmp, name), repoRoot: repoRoot}
}
modPath := filepath.Join(serviceDir, "go.mod")
func (g generatedService) replaceModule(t *testing.T) {
t.Helper()
modPath := filepath.Join(g.dir, "go.mod")
mod, err := os.ReadFile(modPath)
if err != nil {
t.Fatal(err)
}
modText := strings.Replace(string(mod), "go-micro.dev/v6 latest", "go-micro.dev/v6 v6.0.0", 1)
modText += "\nreplace go-micro.dev/v6 => " + filepath.ToSlash(repoRoot) + "\n"
modText += "\nreplace go-micro.dev/v6 => " + filepath.ToSlash(g.repoRoot) + "\n"
if err := os.WriteFile(modPath, []byte(modText), 0644); err != nil {
t.Fatal(err)
}
}
func (g generatedService) build(t *testing.T) {
t.Helper()
cmd := exec.Command("go", "build", "./...")
cmd.Dir = serviceDir
cmd.Dir = g.dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("generated service go build ./... failed: %v\n%s", err, out)
}
}
func (g generatedService) run(t *testing.T) {
t.Helper()
bin := filepath.Join(g.dir, "service-contract")
build := exec.Command("go", "build", "-o", bin, ".")
build.Dir = g.dir
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("generated service go build -o service-contract . failed: %v\n%s", err, out)
}
cmd := exec.Command(bin)
cmd.Dir = g.dir
var out strings.Builder
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Start(); err != nil {
t.Fatalf("generated service failed to start: %v\n%s", err, out.String())
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
t.Fatalf("generated service exited early: %v\n%s", err, out.String())
case <-time.After(2 * time.Second):
}
if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
t.Fatalf("failed to stop generated service: %v\n%s", err, out.String())
}
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatalf("generated service did not stop after kill\n%s", out.String())
}
}
func (g generatedService) call(t *testing.T, name, want string) {
t.Helper()
testPath := filepath.Join(g.dir, "handler", "contract_test.go")
testSrc := `package handler
import (
"context"
"testing"
)
func TestGeneratedCallContract(t *testing.T) {
rsp := new(Response)
if err := New().Call(context.Background(), &Request{Name: "` + name + `"}, rsp); err != nil {
t.Fatal(err)
}
if rsp.Msg != "` + want + `" {
t.Fatalf("Call response = %q, want %q", rsp.Msg, "` + want + `")
}
}
`
if err := os.WriteFile(testPath, []byte(testSrc), 0644); err != nil {
t.Fatal(err)
}
cmd := exec.Command("go", "test", "./handler", "-run", "TestGeneratedCallContract", "-count=1")
cmd.Dir = g.dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("generated service call contract failed: %v\n%s", err, out)
}
}
+23 -9
View File
@@ -6,6 +6,7 @@ import (
"context"
"fmt"
"go/build"
"io"
"os"
"os/exec"
"os/signal"
@@ -280,18 +281,31 @@ func Run(ctx *cli.Context) error {
fmt.Println()
fmt.Printf(" \033[32m✓\033[0m Service \033[36m%s\033[0m created\n\n", dir)
fmt.Println(" Next steps:")
fmt.Printf(" cd %s\n", dir)
fmt.Println(" go run .")
if !noMCP {
fmt.Println()
fmt.Printf(" MCP tools \033[36mhttp://localhost:3001/mcp/tools\033[0m\n")
fmt.Println(" Claude Code \033[2mmicro mcp serve\033[0m")
}
fmt.Println()
printNextSteps(os.Stdout, dir, noMCP)
return nil
}
func printNextSteps(w io.Writer, dir string, noMCP bool) {
fmt.Fprintln(w, " Next steps:")
fmt.Fprintf(w, " cd %s\n", dir)
fmt.Fprintln(w, " micro agent preflight")
fmt.Fprintln(w, " go run .")
fmt.Fprintln(w, " micro chat")
fmt.Fprintln(w, " micro inspect agent <name>")
fmt.Fprintln(w)
fmt.Fprintln(w, " First-agent path:")
fmt.Fprintln(w, " micro agent demo")
fmt.Fprintln(w, " micro docs")
fmt.Fprintln(w, " https://go-micro.dev/docs/guides/your-first-agent.html")
fmt.Fprintln(w, " https://go-micro.dev/docs/guides/zero-to-hero.html")
if !noMCP {
fmt.Fprintln(w)
fmt.Fprintf(w, " MCP tools \033[36mhttp://localhost:3001/mcp/tools\033[0m\n")
fmt.Fprintln(w, " Claude Code \033[2mmicro mcp serve\033[0m")
}
fmt.Fprintln(w)
}
func selectTemplates(name string, noMCP bool) (mainTmpl, handlerTmpl, protoTmpl string) {
switch name {
case "crud":
+162
View File
@@ -0,0 +1,162 @@
package main
import (
"bytes"
"strings"
"testing"
"github.com/urfave/cli/v2"
microcmd "go-micro.dev/v6/cmd"
)
func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
commands := map[string]bool{}
subcommands := map[string]map[string]bool{}
for _, command := range microcmd.DefaultCmd.App().Commands {
commands[command.Name] = true
for _, subcommand := range command.Subcommands {
if subcommands[command.Name] == nil {
subcommands[command.Name] = map[string]bool{}
}
subcommands[command.Name][subcommand.Name] = true
}
}
for _, want := range []string{"new", "run", "chat", "inspect", "agent", "docs", "examples"} {
if !commands[want] {
t.Fatalf("first-agent walkthrough missing %q command", want)
}
}
if !subcommands["agent"]["preflight"] {
t.Fatal("first-agent walkthrough missing preflight boundary: agent preflight")
}
if !subcommands["agent"]["demo"] {
t.Fatal("first-agent walkthrough missing no-secret boundary: agent demo")
}
if !subcommands["agent"]["doctor"] {
t.Fatal("first-agent walkthrough missing recovery boundary: agent doctor")
}
if !subcommands["inspect"]["agent"] {
t.Fatal("first-agent walkthrough missing inspect boundary: inspect agent")
}
chat := commandByName(t, "chat")
if !strings.Contains(chat.Description, "services") || !strings.Contains(chat.Description, "agent") {
t.Fatalf("micro chat should describe the service-to-agent walkthrough boundary; description was %q", chat.Description)
}
docs := commandByName(t, "docs")
if !strings.Contains(docs.Usage, "first-agent") || !strings.Contains(docs.Usage, "0→hero") {
t.Fatalf("micro docs should advertise the first-agent and 0→hero docs path; usage was %q", docs.Usage)
}
var out bytes.Buffer
app := cli.NewApp()
app.Writer = &out
if err := docs.Action(cli.NewContext(app, nil, nil)); err != nil {
t.Fatalf("micro docs failed: %v", err)
}
if demoIdx, guideIdx := strings.Index(out.String(), "micro agent demo"), strings.Index(out.String(), "no-secret-first-agent.html"); demoIdx < 0 || guideIdx < 0 || demoIdx > guideIdx {
t.Fatalf("micro docs should lead with micro agent demo before guide links:\n%s", out.String())
}
for _, want := range []string{
"micro agent demo",
"no-secret-first-agent.html",
"your-first-agent.html",
"debugging-agents.html",
"zero-to-hero.html",
"micro agent preflight # before micro run: prerequisites",
"micro run",
"micro chat",
"micro agent doctor # after micro run: chat/gateway/inspect recovery",
"micro inspect agent <name>",
"micro agent history <name>",
} {
if !strings.Contains(out.String(), want) {
t.Fatalf("micro docs output missing %q:\n%s", want, out.String())
}
}
if strings.Contains(out.String(), "micro runs") {
t.Fatalf("micro docs output should use the first-agent inspect command, not the legacy runs shortcut:\n%s", out.String())
}
examples := commandByName(t, "examples")
if !strings.Contains(examples.Usage, "first-agent") {
t.Fatalf("micro examples should advertise the first-agent examples path; usage was %q", examples.Usage)
}
out.Reset()
if err := examples.Action(cli.NewContext(app, nil, nil)); err != nil {
t.Fatalf("micro examples failed: %v", err)
}
for _, want := range []string{
"First-agent examples",
"go run ./examples/first-agent",
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1",
"go run ./examples/support",
"micro agent demo",
"micro docs",
"micro zero-to-hero",
"no-secret-first-agent.html",
"your-first-agent.html",
"debugging-agents.html",
"zero-to-hero.html",
} {
if !strings.Contains(out.String(), want) {
t.Fatalf("micro examples output missing %q:\n%s", want, out.String())
}
}
agent := commandByName(t, "agent")
if !strings.Contains(agent.Usage, "micro agent demo") {
t.Fatalf("micro agent help should advertise the no-secret demo; usage was %q", agent.Usage)
}
doctor := subcommandByName(t, agent, "doctor")
for _, want := range []string{"chat", "gateway", "registration", "provider", "inspect", "after micro run"} {
if !strings.Contains(doctor.Usage, want) {
t.Fatalf("micro agent doctor usage should advertise after-run recovery for %q; usage was %q", want, doctor.Usage)
}
}
demo := subcommandByName(t, agent, "demo")
out.Reset()
if err := demo.Action(cli.NewContext(app, nil, nil)); err != nil {
t.Fatalf("micro agent demo failed: %v", err)
}
for _, want := range []string{
"No-secret first-agent demo",
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1",
"provider-free",
"micro agent preflight # before micro run: prerequisites",
"micro chat",
"micro agent doctor # after micro run: chat/gateway/inspect recovery",
"micro inspect agent <name>",
"your-first-agent.html",
"debugging-agents.html",
"zero-to-hero.html",
} {
if !strings.Contains(out.String(), want) {
t.Fatalf("micro agent demo output missing %q:\n%s", want, out.String())
}
}
}
func commandByName(t *testing.T, name string) *cli.Command {
t.Helper()
for _, command := range microcmd.DefaultCmd.App().Commands {
if command.Name == name {
return command
}
}
t.Fatalf("missing command %q", name)
return nil
}
func subcommandByName(t *testing.T, command *cli.Command, name string) *cli.Command {
t.Helper()
for _, subcommand := range command.Subcommands {
if subcommand.Name == name {
return subcommand
}
}
t.Fatalf("missing subcommand %q under %q", name, command.Name)
return nil
}
+76
View File
@@ -9,6 +9,7 @@ import (
"io"
"os"
"os/signal"
"sort"
"syscall"
"github.com/urfave/cli/v2"
@@ -75,6 +76,10 @@ Examples:
ArgsUsage: "[name]",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "json", Usage: "Print durable run history as JSON for automation"},
&cli.BoolFlag{Name: "pending", Usage: "Only show runs that have not completed"},
&cli.StringFlag{Name: "status", Usage: "Only show runs with this status (running, done, failed)"},
&cli.IntFlag{Name: "limit", Usage: "Show the most recently updated N runs"},
&cli.StringFlag{Name: "stage", Usage: "Only show runs currently checkpointed at this stage"},
},
Action: flowRuns,
},
@@ -129,19 +134,83 @@ func flowRuns(c *cli.Context) error {
if err != nil {
return err
}
opts, err := validateFlowRunOptions(flowRunOptions{Pending: c.Bool("pending"), Status: c.String("status"), Stage: c.String("stage"), Limit: c.Int("limit")})
if err != nil {
return err
}
runs = filterFlowRuns(runs, opts)
if len(runs) == 0 {
if c.Bool("pending") {
fmt.Printf(" No pending runs recorded for flow %q.\n", name)
return nil
}
fmt.Printf(" No runs recorded for flow %q.\n", name)
return nil
}
return writeFlowRuns(os.Stdout, runs, c.Bool("json"))
}
type flowRunOptions struct {
Pending bool
Status string
Stage string
Limit int
}
func validateFlowRunOptions(opts flowRunOptions) (flowRunOptions, error) {
switch opts.Status {
case "", "running", "done", "failed":
default:
return opts, fmt.Errorf("invalid run status %q: expected running, done, or failed", opts.Status)
}
if opts.Limit < 0 {
return opts, fmt.Errorf("invalid limit %d: expected a non-negative value", opts.Limit)
}
return opts, nil
}
func filterFlowRuns(runs []aiflow.Run, opts flowRunOptions) []aiflow.Run {
if len(runs) == 0 {
return nil
}
filtered := make([]aiflow.Run, 0, len(runs))
for _, run := range runs {
if opts.Pending && run.Status == "done" {
continue
}
if opts.Status != "" && run.Status != opts.Status {
continue
}
if opts.Stage != "" && run.State.Stage != opts.Stage {
continue
}
filtered = append(filtered, run)
}
if opts.Limit > 0 && len(filtered) > opts.Limit {
sort.SliceStable(filtered, func(i, j int) bool {
if filtered[i].Updated.Equal(filtered[j].Updated) {
return filtered[i].Started.Before(filtered[j].Started)
}
return filtered[i].Updated.Before(filtered[j].Updated)
})
start := len(filtered) - opts.Limit
limited := append([]aiflow.Run(nil), filtered[start:]...)
return limited
}
return filtered
}
func pendingFlowRuns(runs []aiflow.Run) []aiflow.Run {
return filterFlowRuns(runs, flowRunOptions{Pending: true})
}
func writeFlowRuns(w io.Writer, runs []aiflow.Run, asJSON bool) error {
if asJSON {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(runs)
}
fmt.Fprintf(w, " %d run%s\n", len(runs), plural(len(runs)))
for _, r := range runs {
id := r.ID
if len(id) > 8 {
@@ -164,6 +233,13 @@ func writeFlowRuns(w io.Writer, runs []aiflow.Run, asJSON bool) error {
return nil
}
func plural(n int) string {
if n == 1 {
return ""
}
return "s"
}
func flowFlags() []cli.Flag {
return []cli.Flag{
&cli.StringFlag{Name: "trigger", Usage: "Broker topic to subscribe to", EnvVars: []string{"MICRO_FLOW_TRIGGER"}},
+99
View File
@@ -29,6 +29,7 @@ func TestWriteFlowRunsIncludesStepDetails(t *testing.T) {
}
got := out.String()
for _, want := range []string{
"1 run",
"12345678 failed stage=charge",
"updated=2026-06-24T12:30:00Z",
"- reserve done attempts=1",
@@ -40,6 +41,20 @@ func TestWriteFlowRunsIncludesStepDetails(t *testing.T) {
}
}
func TestValidateFlowRunOptionsRejectsInvalidStatus(t *testing.T) {
_, err := validateFlowRunOptions(flowRunOptions{Status: "stuck"})
if err == nil || !strings.Contains(err.Error(), "invalid run status") {
t.Fatalf("expected invalid status error, got %v", err)
}
}
func TestValidateFlowRunOptionsRejectsNegativeLimit(t *testing.T) {
_, err := validateFlowRunOptions(flowRunOptions{Limit: -1})
if err == nil || !strings.Contains(err.Error(), "invalid limit") {
t.Fatalf("expected invalid limit error, got %v", err)
}
}
func TestWriteFlowRunsJSON(t *testing.T) {
runs := []aiflow.Run{{ID: "run-1", Flow: "checkout", Status: "done"}}
@@ -55,3 +70,87 @@ func TestWriteFlowRunsJSON(t *testing.T) {
t.Fatalf("decoded runs = %+v", got)
}
}
func TestPendingFlowRunsFiltersCompletedRuns(t *testing.T) {
runs := []aiflow.Run{
{ID: "run-1", Status: "done"},
{ID: "run-2", Status: "failed"},
{ID: "run-3", Status: "running"},
}
got := pendingFlowRuns(runs)
if len(got) != 2 {
t.Fatalf("pendingFlowRuns returned %d runs, want 2: %+v", len(got), got)
}
if got[0].ID != "run-2" || got[1].ID != "run-3" {
t.Fatalf("pending runs = %+v", got)
}
}
func TestFilterFlowRunsStatus(t *testing.T) {
runs := []aiflow.Run{
{ID: "run-1", Status: "done"},
{ID: "run-2", Status: "failed"},
{ID: "run-3", Status: "running"},
{ID: "run-4", Status: "failed"},
}
got := filterFlowRuns(runs, flowRunOptions{Status: "failed"})
if len(got) != 2 {
t.Fatalf("filterFlowRuns returned %d runs, want 2: %+v", len(got), got)
}
if got[0].ID != "run-2" || got[1].ID != "run-4" {
t.Fatalf("failed runs = %+v", got)
}
}
func TestFilterFlowRunsStage(t *testing.T) {
runs := []aiflow.Run{
{ID: "run-1", Status: "failed", State: aiflow.State{Stage: "reserve"}},
{ID: "run-2", Status: "failed", State: aiflow.State{Stage: "charge"}},
{ID: "run-3", Status: "running", State: aiflow.State{Stage: "charge"}},
{ID: "run-4", Status: "done", State: aiflow.State{}},
}
got := filterFlowRuns(runs, flowRunOptions{Stage: "charge"})
if len(got) != 2 {
t.Fatalf("filterFlowRuns returned %d runs, want 2: %+v", len(got), got)
}
if got[0].ID != "run-2" || got[1].ID != "run-3" {
t.Fatalf("charge-stage runs = %+v", got)
}
}
func TestFilterFlowRunsLimitKeepsNewestRuns(t *testing.T) {
runs := []aiflow.Run{
{ID: "run-1", Status: "done"},
{ID: "run-2", Status: "failed"},
{ID: "run-3", Status: "running"},
}
got := filterFlowRuns(runs, flowRunOptions{Limit: 2})
if len(got) != 2 {
t.Fatalf("filterFlowRuns returned %d runs, want 2: %+v", len(got), got)
}
if got[0].ID != "run-2" || got[1].ID != "run-3" {
t.Fatalf("limited runs = %+v", got)
}
}
func TestFilterFlowRunsCombinesPendingStatusAndLimit(t *testing.T) {
runs := []aiflow.Run{
{ID: "run-1", Status: "failed"},
{ID: "run-2", Status: "done"},
{ID: "run-3", Status: "failed"},
{ID: "run-4", Status: "running"},
{ID: "run-5", Status: "failed"},
}
got := filterFlowRuns(runs, flowRunOptions{Pending: true, Status: "failed", Limit: 2})
if len(got) != 2 {
t.Fatalf("filterFlowRuns returned %d runs, want 2: %+v", len(got), got)
}
if got[0].ID != "run-3" || got[1].ID != "run-5" {
t.Fatalf("filtered runs = %+v", got)
}
}
+190
View File
@@ -0,0 +1,190 @@
// Package inspect registers the 'micro inspect' CLI command.
package inspect
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"github.com/urfave/cli/v2"
goagent "go-micro.dev/v6/agent"
"go-micro.dev/v6/cmd"
aiflow "go-micro.dev/v6/flow"
"go-micro.dev/v6/store"
)
func init() {
cmd.Register(&cli.Command{
Name: "inspect",
Usage: "Inspect recent agent and workflow activity",
Description: `Inspect is the CLI checkpoint in the local scaffold run chat inspect loop.
It reads durable local run history, so it works after the agent or flow has stopped.`,
Subcommands: []*cli.Command{
{
Name: "agent",
Usage: "Show recent recorded runs for an agent",
ArgsUsage: "[agent]",
Flags: inspectAgentFlags(),
Action: inspectAgent,
},
{
Name: "flow",
Usage: "Show durable run history for a flow",
ArgsUsage: "[flow]",
Flags: inspectFlowFlags(),
Action: inspectFlow,
},
},
})
}
func inspectAgentFlags() []cli.Flag {
return []cli.Flag{
&cli.BoolFlag{Name: "json", Usage: "Print run summaries as JSON for automation"},
&cli.StringFlag{Name: "status", Usage: "Only show runs with this status (running, done, error, refused)"},
&cli.StringFlag{Name: "trace", Usage: "Only show runs whose trace id matches this full id or prefix"},
&cli.IntFlag{Name: "limit", Usage: "Show the most recently updated N runs"},
}
}
func inspectFlowFlags() []cli.Flag {
return []cli.Flag{
&cli.BoolFlag{Name: "json", Usage: "Print durable run history as JSON for automation"},
&cli.BoolFlag{Name: "pending", Usage: "Only show runs that have not completed"},
&cli.StringFlag{Name: "status", Usage: "Only show runs with this status (running, done, failed)"},
&cli.IntFlag{Name: "limit", Usage: "Show the most recently updated N runs"},
&cli.StringFlag{Name: "stage", Usage: "Only show runs currently checkpointed at this stage"},
}
}
func inspectAgent(c *cli.Context) error {
name := c.Args().First()
if name == "" {
return fmt.Errorf("agent name required: micro inspect agent <name>")
}
opts := goagent.RunListOptions{Status: c.String("status"), TraceID: c.String("trace"), Limit: c.Int("limit")}
runs, err := goagent.ListRunSummariesWithOptions(store.DefaultStore, name, opts)
if err != nil {
return err
}
return writeAgentInspection(os.Stdout, name, runs, c.Bool("json"))
}
func writeAgentInspection(w io.Writer, name string, runs []goagent.RunSummary, asJSON bool) error {
if asJSON {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(runs)
}
if len(runs) == 0 {
fmt.Fprintf(w, " No agent runs recorded for %q. After chatting, try: micro inspect agent %s\n", name, name)
return nil
}
fmt.Fprintf(w, " Agent %q runs\n", name)
for _, run := range runs {
fmt.Fprintf(w, " %s status=%s events=%d last=%s", run.RunID, run.Status, run.Events, run.LastKind)
if run.Checkpoint != "" {
fmt.Fprintf(w, " checkpoint=%s", run.Checkpoint)
}
if run.Stage != "" {
fmt.Fprintf(w, " stage=%s", run.Stage)
}
if run.LastError != "" {
fmt.Fprintf(w, " error=%q", run.LastError)
}
if run.TraceID != "" {
fmt.Fprintf(w, " trace=%s", shortID(run.TraceID))
}
fmt.Fprintln(w)
if isResumableAgentRun(run) {
fmt.Fprintf(w, " resume: call micro.AgentResume(ctx, agent, %q) after recreating the agent with the same checkpoint store\n", run.RunID)
}
if run.Stage == "input-required" {
fmt.Fprintf(w, " input: call micro.AgentResumeInput(ctx, agent, %q, input) to continue the paused run\n", run.RunID)
}
}
return nil
}
func isResumableAgentRun(run goagent.RunSummary) bool {
switch run.Status {
case "running", "error", "failed", "refused":
return run.Checkpoint != "done" || run.Stage != ""
default:
return false
}
}
func inspectFlow(c *cli.Context) error {
name := c.Args().First()
if name == "" {
return fmt.Errorf("flow name required: micro inspect flow <name>")
}
runs, err := aiflow.StoreCheckpoint(nil, name).List(context.Background())
if err != nil {
return err
}
runs = filterFlowInspection(runs, c.Bool("pending"), c.String("status"), c.String("stage"), c.Int("limit"))
return writeFlowInspection(os.Stdout, name, runs, c.Bool("json"), c.Bool("pending"))
}
func filterFlowInspection(runs []aiflow.Run, pending bool, status, stage string, limit int) []aiflow.Run {
filtered := make([]aiflow.Run, 0, len(runs))
for _, run := range runs {
if pending && run.Status == "done" {
continue
}
if status != "" && run.Status != status {
continue
}
if stage != "" && run.State.Stage != stage {
continue
}
filtered = append(filtered, run)
}
if limit > 0 && len(filtered) > limit {
return filtered[len(filtered)-limit:]
}
return filtered
}
func writeFlowInspection(w io.Writer, name string, runs []aiflow.Run, asJSON, pending bool) error {
if asJSON {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(runs)
}
if len(runs) == 0 {
if pending {
fmt.Fprintf(w, " No pending flow runs recorded for %q.\n", name)
return nil
}
fmt.Fprintf(w, " No flow runs recorded for %q. After executing a durable flow, try: micro inspect flow %s\n", name, name)
return nil
}
fmt.Fprintf(w, " Flow %q runs\n", name)
for _, run := range runs {
stage := run.State.Stage
if stage == "" {
stage = "-"
}
fmt.Fprintf(w, " %s status=%s stage=%s steps=%d", shortID(run.ID), run.Status, stage, len(run.Steps))
for _, step := range run.Steps {
if step.Error != "" {
fmt.Fprintf(w, " error=%q", step.Error)
break
}
}
fmt.Fprintln(w)
}
return nil
}
func shortID(id string) string {
if len(id) <= 12 {
return id
}
return id[:12]
}
+78
View File
@@ -0,0 +1,78 @@
package inspect
import (
"bytes"
"encoding/json"
"strings"
"testing"
goagent "go-micro.dev/v6/agent"
aiflow "go-micro.dev/v6/flow"
)
func TestWriteAgentInspectionIncludesActionableBreadcrumbs(t *testing.T) {
runs := []goagent.RunSummary{{RunID: "run-1", Status: "error", Events: 4, LastKind: "tool", LastError: "boom", TraceID: "1234567890abcdef", Checkpoint: "failed", Stage: "ask"}}
var out bytes.Buffer
if err := writeAgentInspection(&out, "support", runs, false); err != nil {
t.Fatal(err)
}
got := out.String()
for _, want := range []string{"Agent \"support\" runs", "run-1", "status=error", "events=4", "last=tool", "checkpoint=failed", "stage=ask", `error="boom"`, "trace=1234567890ab", `micro.AgentResume(ctx, agent, "run-1")`} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestWriteAgentInspectionIncludesInputResumeBreadcrumb(t *testing.T) {
runs := []goagent.RunSummary{{RunID: "run-input", Status: "running", Events: 3, LastKind: "checkpoint", Checkpoint: "paused", Stage: "input-required"}}
var out bytes.Buffer
if err := writeAgentInspection(&out, "support", runs, false); err != nil {
t.Fatal(err)
}
got := out.String()
for _, want := range []string{"checkpoint=paused", "stage=input-required", `micro.AgentResumeInput(ctx, agent, "run-input", input)`} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestWriteAgentInspectionEmptyStateNamesInspectCommand(t *testing.T) {
var out bytes.Buffer
if err := writeAgentInspection(&out, "support", nil, false); err != nil {
t.Fatal(err)
}
if got := out.String(); !strings.Contains(got, "micro inspect agent support") {
t.Fatalf("empty state missing next step: %q", got)
}
}
func TestWriteFlowInspectionIncludesFailedStepBreadcrumb(t *testing.T) {
runs := []aiflow.Run{{ID: "1234567890abcdef", Status: "failed", State: aiflow.State{Stage: "charge"}, Steps: []aiflow.StepRecord{{Name: "charge", Status: "failed", Error: "card declined"}}}}
var out bytes.Buffer
if err := writeFlowInspection(&out, "checkout", runs, false, false); err != nil {
t.Fatal(err)
}
got := out.String()
for _, want := range []string{"Flow \"checkout\" runs", "1234567890ab", "status=failed", "stage=charge", "steps=1", `error="card declined"`} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestWriteFlowInspectionJSON(t *testing.T) {
runs := []aiflow.Run{{ID: "run-1", Flow: "checkout", Status: "done"}}
var out bytes.Buffer
if err := writeFlowInspection(&out, "checkout", runs, true, false); err != nil {
t.Fatal(err)
}
var got []aiflow.Run
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, out.String())
}
if len(got) != 1 || got[0].ID != "run-1" || got[0].Status != "done" {
t.Fatalf("decoded runs = %+v", got)
}
}
+497
View File
@@ -0,0 +1,497 @@
// Package loop implements the 'micro loop' command, which scaffolds and
// verifies an autonomous improvement loop for a repository.
//
// The loop is a set of GitHub Actions workflows that dispatch a coding agent by
// @mention on a fresh tracking issue each run. It has up to five roles:
//
// planner keeps a ranked queue in .github/loop/PRIORITIES.md
// builder builds the top open item as a single-concern PR (auto-merged on green CI)
// triage turns CI failures into scoped fix issues back into the queue
// coherence keeps README/docs/CHANGELOG aligned with the North Star (opt-in)
// release cuts the next patch tag when the branch has new commits (opt-in)
//
// The workflows are the MECHANISM; each dispatch role's instruction lives in an
// editable .github/loop/prompts/<role>.md file — the POLICY. That split is what
// lets any repo (including go-micro itself) customize behavior by editing prompt
// files rather than forking the CLI. `micro loop init` writes it all; `micro
// loop verify` checks the wiring.
package loop
import (
"bytes"
"embed"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"text/template"
"github.com/urfave/cli/v2"
"go-micro.dev/v6/cmd"
)
//go:embed templates/*
var templatesFS embed.FS
// config is the substitution surface for the templates — the whole config-vs-core
// boundary. The workflows and prompts are the reusable core; these are what a
// given repo tunes.
type config struct {
// Shared.
DefaultBranch string // base branch for the loop's PRs (e.g. main)
AgentMention string // how the workflows summon the agent (e.g. @codex)
TokenSecret string // repo secret holding the user PAT that drives dispatch
CIWorkflow string // human-readable CI workflow name(s) triage watches
CIWorkflowsYAML string // the same as a YAML array literal, e.g. ["Lint", "Run Tests"]
// Per-dispatch-role (set while rendering each one).
Role string
WorkflowName string
IssueTitle string
Group string
Cron string
// Release role.
TagPrefix string // tag prefix to match/bump, e.g. "v"
ReleaseCron string
}
// dispatchRole is a cron-driven role rendered from templates/dispatch.yml.tmpl.
type dispatchRole struct {
workflowName string
issueTitle string
group string
cronFlag string
defaultCron string
}
var dispatchRoles = map[string]dispatchRole{
"planner": {"Loop: Planner", "Loop: planning review", "loop-planner", "planner-cron", "0 * * * *"},
"builder": {"Loop: Builder", "Loop: build increment", "loop-builder", "builder-cron", "30 * * * *"},
"coherence": {"Loop: Coherence", "Loop: coherence review", "loop-coherence", "coherence-cron", "0 7 * * *"},
"security": {"Loop: Security", "Loop: security review", "loop-security", "security-cron", "0 6 * * 1"},
}
// allRoles is the full set, in a stable order, for --roles=all and help text.
var allRoles = []string{"planner", "builder", "triage", "coherence", "security", "release"}
const (
promptDir = ".github/loop/prompts"
loopDir = ".github/loop"
wfDir = ".github/workflows"
)
func init() {
cmd.Register(&cli.Command{
Name: "loop",
Usage: "Scaffold an autonomous improvement loop for a repository",
Description: `Set up a self-improving loop for a repo: GitHub Actions workflows that
dispatch a coding agent to plan, build, triage, and (optionally) keep docs
coherent and cut releases gated by CI.
Roles (choose with --roles, default: planner,builder,triage):
planner keeps a ranked queue in .github/loop/PRIORITIES.md
builder builds the top open item as a single-concern PR (auto-merged on green CI)
triage turns CI failures into scoped fix issues back into the queue
coherence keeps README/docs/CHANGELOG aligned with the North Star
security audits for vulnerabilities and files them (fixes stay human-reviewed)
release cuts the next patch tag when the branch has new commits
Each dispatch role's instruction is an editable file in .github/loop/prompts/
edit those to steer behavior. Direction lives in .github/loop/NORTH_STAR.md.
Examples:
# Scaffold the default loop (planner, builder, triage)
micro loop init
# The full loop, all five roles
micro loop init --roles all
# Customize the agent, token secret, base branch, and CI workflow name
micro loop init --agent @codex --token-secret LOOP_TOKEN \
--branch main --ci-workflow CI
# Check that a repo is wired correctly
micro loop verify`,
Subcommands: []*cli.Command{
{
Name: "init",
Usage: "Scaffold the loop workflows, prompts, and queue into a repo",
Flags: []cli.Flag{
&cli.StringFlag{Name: "dir", Usage: "Target repo directory", Value: "."},
&cli.StringFlag{Name: "roles", Usage: "Comma-separated roles, or 'all'", Value: "planner,builder,triage"},
&cli.StringFlag{Name: "branch", Usage: "Base branch for the loop's PRs (auto-detected if empty)"},
&cli.StringFlag{Name: "agent", Usage: "How the workflows summon the agent (an @mention)", Value: "@codex"},
&cli.StringFlag{Name: "token-secret", Usage: "Repo secret holding the user PAT that drives dispatch", Value: "LOOP_TOKEN"},
&cli.StringFlag{Name: "ci-workflow", Usage: "CI workflow name(s) triage watches for failures (comma-separated)", Value: "CI"},
&cli.StringFlag{Name: "planner-cron", Usage: "Cron schedule for the planner", Value: "0 * * * *"},
&cli.StringFlag{Name: "builder-cron", Usage: "Cron schedule for the builder", Value: "30 * * * *"},
&cli.StringFlag{Name: "coherence-cron", Usage: "Cron schedule for the coherence role", Value: "0 7 * * *"},
&cli.StringFlag{Name: "security-cron", Usage: "Cron schedule for the security role", Value: "0 6 * * 1"},
&cli.StringFlag{Name: "release-cron", Usage: "Cron schedule for the release role", Value: "0 23 * * *"},
&cli.StringFlag{Name: "tag-prefix", Usage: "Tag prefix the release role matches and bumps", Value: "v"},
&cli.BoolFlag{Name: "force", Usage: "Overwrite existing loop files"},
},
Action: runInit,
},
{
Name: "verify",
Usage: "Verify a repo is wired for the loop",
Flags: []cli.Flag{&cli.StringFlag{Name: "dir", Usage: "Target repo directory", Value: "."}},
Action: runVerify,
},
},
})
}
func runInit(c *cli.Context) error {
dir := c.String("dir")
roles, err := parseRoles(c.String("roles"))
if err != nil {
return err
}
ciNames := splitCSV(c.String("ci-workflow"))
cfg := config{
DefaultBranch: c.String("branch"),
AgentMention: strings.TrimSpace(c.String("agent")),
TokenSecret: strings.TrimSpace(c.String("token-secret")),
CIWorkflow: strings.Join(ciNames, ", "),
CIWorkflowsYAML: yamlStringArray(ciNames),
TagPrefix: c.String("tag-prefix"),
ReleaseCron: c.String("release-cron"),
}
if cfg.DefaultBranch == "" {
cfg.DefaultBranch = detectDefaultBranch(dir)
}
if !strings.HasPrefix(cfg.AgentMention, "@") {
cfg.AgentMention = "@" + cfg.AgentMention
}
crons := map[string]string{
"planner": c.String("planner-cron"),
"builder": c.String("builder-cron"),
"coherence": c.String("coherence-cron"),
"security": c.String("security-cron"),
}
if err := scaffold(dir, cfg, roles, crons, c.Bool("force")); err != nil {
return err
}
printNextSteps(cfg, roles)
return nil
}
// parseRoles resolves the --roles flag into a validated, stable-ordered set.
func parseRoles(spec string) ([]string, error) {
if strings.TrimSpace(spec) == "all" {
return append([]string(nil), allRoles...), nil
}
want := map[string]bool{}
for _, r := range strings.Split(spec, ",") {
r = strings.TrimSpace(r)
if r == "" {
continue
}
if !isRole(r) {
return nil, fmt.Errorf("unknown role %q (valid: %s, or 'all')", r, strings.Join(allRoles, ", "))
}
want[r] = true
}
if len(want) == 0 {
return nil, fmt.Errorf("no roles selected")
}
var out []string
for _, r := range allRoles { // preserve canonical order
if want[r] {
out = append(out, r)
}
}
return out, nil
}
// splitCSV splits a comma-separated flag into trimmed, non-empty values.
func splitCSV(s string) []string {
var out []string
for _, v := range strings.Split(s, ",") {
if v = strings.TrimSpace(v); v != "" {
out = append(out, v)
}
}
if len(out) == 0 {
out = []string{"CI"}
}
return out
}
// yamlStringArray renders names as a YAML/JSON flow array, e.g. ["Lint", "Run Tests"].
// Names are known workflow display names (no embedded quotes), so a simple quote is safe.
func yamlStringArray(names []string) string {
quoted := make([]string, len(names))
for i, n := range names {
quoted[i] = fmt.Sprintf("%q", n)
}
return "[" + strings.Join(quoted, ", ") + "]"
}
func isRole(r string) bool {
for _, x := range allRoles {
if x == r {
return true
}
}
return false
}
// scaffold renders the selected roles into dir. The split is deliberate:
// - Workflows are the MECHANISM — regenerated, and overwritten with --force.
// - Prompts, NORTH_STAR, and PRIORITIES are the POLICY — written once and
// never clobbered, even with --force, so re-running init to refresh the
// workflow mechanics can't wipe curated instructions, direction, or queue.
func scaffold(dir string, cfg config, roles []string, crons map[string]string, force bool) error {
for _, role := range roles {
switch role {
case "triage":
if err := renderTo(dir, "templates/loop-triage.yml.tmpl", filepath.Join(wfDir, "loop-triage.yml"), cfg, force); err != nil {
return err
}
if err := renderKeep(dir, "templates/prompts/triage.md.tmpl", filepath.Join(promptDir, "triage.md"), cfg); err != nil {
return err
}
case "release":
if err := renderTo(dir, "templates/loop-release.yml.tmpl", filepath.Join(wfDir, "loop-release.yml"), cfg, force); err != nil {
return err
}
default: // dispatch roles
d := dispatchRoles[role]
rc := cfg
rc.Role = role
rc.WorkflowName = d.workflowName
rc.IssueTitle = d.issueTitle
rc.Group = d.group
rc.Cron = crons[role]
if rc.Cron == "" {
rc.Cron = d.defaultCron
}
if err := renderTo(dir, "templates/dispatch.yml.tmpl", filepath.Join(wfDir, "loop-"+role+".yml"), rc, force); err != nil {
return err
}
if err := renderKeep(dir, "templates/prompts/"+role+".md.tmpl", filepath.Join(promptDir, role+".md"), cfg); err != nil {
return err
}
}
}
// Direction + queue: policy, written once, never clobbered.
if err := renderKeep(dir, "templates/NORTH_STAR.md", filepath.Join(loopDir, "NORTH_STAR.md"), cfg); err != nil {
return err
}
return renderKeep(dir, "templates/PRIORITIES.md", filepath.Join(loopDir, "PRIORITIES.md"), cfg)
}
// renderTo renders a template with cfg and writes it to dir/dest (honoring force).
func renderTo(dir, tmplName, dest string, cfg config, force bool) error {
rendered, err := render(tmplName, cfg)
if err != nil {
return err
}
if err := writeFile(filepath.Join(dir, dest), rendered, force); err != nil {
return err
}
fmt.Printf(" wrote %s\n", dest)
return nil
}
// renderKeep writes dir/dest only if it does not already exist — used for
// policy files (prompts, North Star, queue) so re-running init never clobbers
// customizations, regardless of --force.
func renderKeep(dir, tmplName, dest string, cfg config) error {
full := filepath.Join(dir, dest)
if fileExists(full) {
fmt.Printf(" kept %s (already exists)\n", dest)
return nil
}
rendered, err := render(tmplName, cfg)
if err != nil {
return err
}
if err := writeFile(full, rendered, true); err != nil {
return err
}
fmt.Printf(" wrote %s\n", dest)
return nil
}
// verifyState reports what's wrong with dir's loop setup: warnings are
// non-fatal, missing are required files that aren't present.
func verifyState(dir string) (warnings, missing []string) {
// A loop needs direction, a queue, and at least one role workflow.
for _, dest := range []string{filepath.Join(loopDir, "NORTH_STAR.md"), filepath.Join(loopDir, "PRIORITIES.md")} {
if !fileExists(filepath.Join(dir, dest)) {
missing = append(missing, dest)
}
}
present := presentLoopWorkflows(dir)
if len(present) == 0 {
missing = append(missing, wfDir+"/loop-*.yml (no role workflows found)")
}
// Every dispatch/triage role workflow needs its prompt file. (release has none.)
for _, role := range present {
if role == "release" {
continue
}
prompt := filepath.Join(promptDir, role+".md")
if !fileExists(filepath.Join(dir, prompt)) {
missing = append(missing, prompt+" (prompt for the loop-"+role+" workflow)")
}
}
// The loop is only as good as its gate.
if !hasCIWorkflow(dir) {
warnings = append(warnings, "no non-loop workflow found in "+wfDir+" — the loop needs a CI gate (build/test/lint) to merge safely")
}
return warnings, missing
}
// presentLoopWorkflows returns the role names for which a loop-<role>.yml exists.
func presentLoopWorkflows(dir string) []string {
entries, err := os.ReadDir(filepath.Join(dir, wfDir))
if err != nil {
return nil
}
var out []string
for _, e := range entries {
name := e.Name()
if !strings.HasPrefix(name, "loop-") {
continue
}
role := strings.TrimSuffix(strings.TrimSuffix(strings.TrimPrefix(name, "loop-"), ".yml"), ".yaml")
out = append(out, role)
}
sort.Strings(out)
return out
}
func runVerify(c *cli.Context) error {
dir := c.String("dir")
warnings, missing := verifyState(dir)
for _, m := range missing {
fmt.Printf(" MISSING %s\n", m)
}
for _, w := range warnings {
fmt.Printf(" WARN %s\n", w)
}
if len(missing) > 0 {
return fmt.Errorf("loop is not fully scaffolded (%d item(s) missing) — run `micro loop init`", len(missing))
}
fmt.Printf(" OK loop is wired: %s\n", strings.Join(presentLoopWorkflows(dir), ", "))
fmt.Println()
fmt.Println("Reminders the CLI can't check:")
fmt.Println(" • The token secret must be set in the repo (Settings → Secrets).")
fmt.Println(" • Branch protection must require the CI checks with 0 approvals,")
fmt.Println(" so the builder's auto-merge can land PRs on green CI.")
if len(warnings) > 0 {
return fmt.Errorf("%d warning(s) — see above", len(warnings))
}
return nil
}
func render(tmplName string, cfg config) ([]byte, error) {
b, err := templatesFS.ReadFile(tmplName)
if err != nil {
return nil, err
}
// Custom delimiters so GitHub Actions' own ${{ }} expressions pass through
// untouched — only << >> placeholders are substituted.
t, err := template.New(filepath.Base(tmplName)).Delims("<<", ">>").Option("missingkey=error").Parse(string(b))
if err != nil {
return nil, fmt.Errorf("parse %s: %w", tmplName, err)
}
var buf bytes.Buffer
if err := t.Execute(&buf, cfg); err != nil {
return nil, fmt.Errorf("render %s: %w", tmplName, err)
}
return buf.Bytes(), nil
}
func writeFile(path string, content []byte, force bool) error {
if fileExists(path) && !force {
return fmt.Errorf("%s already exists (use --force to overwrite)", path)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, content, 0o644)
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
// hasCIWorkflow reports whether .github/workflows holds any workflow that is
// not one of the loop's own (i.e. a plausible CI gate).
func hasCIWorkflow(dir string) bool {
entries, err := os.ReadDir(filepath.Join(dir, wfDir))
if err != nil {
return false
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.HasPrefix(name, "loop-") {
continue
}
if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") {
return true
}
}
return false
}
// detectDefaultBranch best-effort resolves the repo's default branch, falling
// back to "main".
func detectDefaultBranch(dir string) string {
out, err := exec.Command("git", "-C", dir, "symbolic-ref", "--short", "refs/remotes/origin/HEAD").Output()
if err == nil {
ref := strings.TrimSpace(string(out))
if i := strings.LastIndex(ref, "/"); i >= 0 {
ref = ref[i+1:]
}
if ref != "" {
return ref
}
}
return "main"
}
func printNextSteps(cfg config, roles []string) {
fmt.Printf(`
Loop scaffolded (%s). Next steps (the CLI can't do these for you):
1. Edit .github/loop/NORTH_STAR.md the direction the loop aligns to.
Seed .github/loop/PRIORITIES.md with a few real items.
Tune the per-role instructions in .github/loop/prompts/ if you like.
2. Add a repo secret named %s: a fine-grained user PAT (contents + pull
requests + issues write) for an account the agent (%s) responds to.
The workflows no-op until this secret exists.
3. Ensure a CI workflow named %q exists and that branch protection on %q
requires its checks with 0 approving reviews that green-CI gate is
what lets the builder auto-merge safely.
4. Commit these files, then trigger a run from the Actions tab.
Verify anytime with: micro loop verify
`, strings.Join(roles, ", "), cfg.TokenSecret, cfg.AgentMention, cfg.CIWorkflow, cfg.DefaultBranch)
}
+283
View File
@@ -0,0 +1,283 @@
package loop
import (
"os"
"path/filepath"
"strings"
"testing"
)
var testCfg = config{
DefaultBranch: "main",
AgentMention: "@codex",
TokenSecret: "LOOP_TOKEN",
CIWorkflow: "CI",
CIWorkflowsYAML: `["CI"]`,
TagPrefix: "v",
ReleaseCron: "0 23 * * *",
}
var testCrons = map[string]string{"planner": "0 * * * *", "builder": "30 * * * *", "coherence": "0 7 * * *"}
// renderable is every template a full scaffold touches, with the per-role config
// applied the same way scaffold does.
func renderCases() map[string]config {
cases := map[string]config{
"templates/loop-triage.yml.tmpl": testCfg,
"templates/loop-release.yml.tmpl": testCfg,
"templates/prompts/triage.md.tmpl": testCfg,
"templates/prompts/planner.md.tmpl": testCfg,
"templates/prompts/builder.md.tmpl": testCfg,
"templates/prompts/coherence.md.tmpl": testCfg,
"templates/prompts/security.md.tmpl": testCfg,
}
for role, d := range dispatchRoles {
rc := testCfg
rc.Role, rc.WorkflowName, rc.IssueTitle, rc.Group, rc.Cron = role, d.workflowName, d.issueTitle, d.group, d.defaultCron
cases["dispatch:"+role] = rc
}
return cases
}
func TestRenderIsPlaceholderFreeAndKeepsGHAExpressions(t *testing.T) {
for name, cfg := range renderCases() {
tmplName := name
if strings.HasPrefix(name, "dispatch:") {
tmplName = "templates/dispatch.yml.tmpl"
}
rendered, err := render(tmplName, cfg)
if err != nil {
t.Fatalf("render %s: %v", name, err)
}
s := string(rendered)
// No unresolved substitution delimiters remain in any template.
if strings.Contains(s, "<<") || strings.Contains(s, ">>") {
t.Errorf("%s still contains << >> placeholders", name)
}
}
}
func TestBaseBranchSubstitutedIntoPrompts(t *testing.T) {
// The base branch appears in the PR-opening instructions of these prompts.
for _, p := range []string{"planner", "builder", "coherence", "security"} {
s := mustRender(t, "templates/prompts/"+p+".md.tmpl", testCfg)
if !strings.Contains(s, "--base main") {
t.Errorf("%s prompt missing substituted base branch", p)
}
}
}
func TestWorkflowTemplatesPreserveGHAAndAreStructural(t *testing.T) {
// Only the workflow YAML templates (not the markdown prompts).
wf := map[string]config{
"templates/loop-triage.yml.tmpl": testCfg,
"templates/loop-release.yml.tmpl": testCfg,
}
for role, d := range dispatchRoles {
rc := testCfg
rc.Role, rc.WorkflowName, rc.IssueTitle, rc.Group, rc.Cron = role, d.workflowName, d.issueTitle, d.group, d.defaultCron
wf["dispatch:"+role] = rc
}
for name, cfg := range wf {
tmplName := name
if strings.HasPrefix(name, "dispatch:") {
tmplName = "templates/dispatch.yml.tmpl"
}
s := mustRender(t, tmplName, cfg)
if !strings.Contains(s, "${{ secrets.LOOP_TOKEN") {
t.Errorf("%s lost its ${{ secrets.LOOP_TOKEN }} expression", name)
}
for _, key := range []string{"name:", "on:", "jobs:"} {
if !strings.Contains(s, key) {
t.Errorf("%s missing top-level %q", name, key)
}
}
}
}
func TestDispatchWorkflowsStripPromptComments(t *testing.T) {
// The posted body must not include the prompt's editorial <!-- --> header;
// the workflow strips it. Guard the sed directive in both dispatch paths.
rc := testCfg
d := dispatchRoles["planner"]
rc.Role, rc.WorkflowName, rc.IssueTitle, rc.Group, rc.Cron = "planner", d.workflowName, d.issueTitle, d.group, d.defaultCron
for _, tc := range []struct {
name, tmpl string
cfg config
}{
{"dispatch", "templates/dispatch.yml.tmpl", rc},
{"triage", "templates/loop-triage.yml.tmpl", testCfg},
} {
s := mustRender(t, tc.tmpl, tc.cfg)
if !strings.Contains(s, `/<!--/,/-->/d`) {
t.Errorf("%s workflow does not strip prompt HTML comments before posting", tc.name)
}
}
}
func TestPromptsLeaveRuntimeTokensLiteral(t *testing.T) {
// __ISSUE__ must survive render (the workflow substitutes it at runtime).
for _, p := range []string{"planner", "builder", "coherence", "triage", "security"} {
s := mustRender(t, "templates/prompts/"+p+".md.tmpl", testCfg)
if !strings.Contains(s, "__ISSUE__") {
t.Errorf("%s prompt lost its __ISSUE__ runtime token", p)
}
}
// triage additionally uses __RUNURL__.
if s := mustRender(t, "templates/prompts/triage.md.tmpl", testCfg); !strings.Contains(s, "__RUNURL__") {
t.Error("triage prompt lost its __RUNURL__ runtime token")
}
}
func TestScaffoldAllRolesWritesEverything(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, wfDir, "ci.yml"), "name: CI\n")
roles := []string{"planner", "builder", "triage", "coherence", "security", "release"}
if err := scaffold(dir, testCfg, roles, testCrons, false); err != nil {
t.Fatalf("scaffold: %v", err)
}
wantWorkflows := []string{"loop-planner.yml", "loop-builder.yml", "loop-triage.yml", "loop-coherence.yml", "loop-security.yml", "loop-release.yml"}
for _, w := range wantWorkflows {
if !fileExists(filepath.Join(dir, wfDir, w)) {
t.Errorf("expected %s", w)
}
}
// Dispatch + triage roles have prompts; release does not.
for _, p := range []string{"planner.md", "builder.md", "triage.md", "coherence.md", "security.md"} {
if !fileExists(filepath.Join(dir, promptDir, p)) {
t.Errorf("expected prompt %s", p)
}
}
if fileExists(filepath.Join(dir, promptDir, "release.md")) {
t.Error("release should not have a prompt")
}
if _, missing := verifyState(dir); len(missing) != 0 {
t.Errorf("verify reported missing after full scaffold: %v", missing)
}
}
func TestScaffoldDefaultRolesOmitsOptional(t *testing.T) {
dir := t.TempDir()
if err := scaffold(dir, testCfg, []string{"planner", "builder", "triage"}, testCrons, false); err != nil {
t.Fatalf("scaffold: %v", err)
}
if fileExists(filepath.Join(dir, wfDir, "loop-coherence.yml")) {
t.Error("coherence should not be written by default")
}
if fileExists(filepath.Join(dir, wfDir, "loop-release.yml")) {
t.Error("release should not be written by default")
}
}
func TestReinitForceKeepsPromptsRefreshesWorkflows(t *testing.T) {
dir := t.TempDir()
roles := []string{"planner", "builder", "triage"}
if err := scaffold(dir, testCfg, roles, testCrons, false); err != nil {
t.Fatalf("scaffold: %v", err)
}
// Customize a prompt and edit direction/queue, as a real user would.
customPrompt := filepath.Join(dir, promptDir, "builder.md")
mustWrite(t, customPrompt, "MY CUSTOM BUILDER POLICY")
northStar := filepath.Join(dir, loopDir, "NORTH_STAR.md")
mustWrite(t, northStar, "MY MISSION")
// Re-run with --force to refresh workflow mechanics.
if err := scaffold(dir, testCfg, roles, testCrons, true); err != nil {
t.Fatalf("re-scaffold --force: %v", err)
}
// Policy (prompt, North Star) must survive --force untouched.
if b, _ := os.ReadFile(customPrompt); string(b) != "MY CUSTOM BUILDER POLICY" {
t.Errorf("--force clobbered a customized prompt: %q", b)
}
if b, _ := os.ReadFile(northStar); string(b) != "MY MISSION" {
t.Errorf("--force clobbered the North Star: %q", b)
}
// Mechanism (workflow) must be regenerated (present and non-empty).
if b, _ := os.ReadFile(filepath.Join(dir, wfDir, "loop-builder.yml")); !strings.Contains(string(b), "Loop: Builder") {
t.Error("--force did not refresh the workflow")
}
}
func TestCIWorkflowListRendersAsYAMLArray(t *testing.T) {
if got := yamlStringArray([]string{"Harness (E2E)", "Lint", "Run Tests"}); got != `["Harness (E2E)", "Lint", "Run Tests"]` {
t.Errorf("yamlStringArray = %q", got)
}
if got := splitCSV("Harness (E2E), Lint ,Run Tests"); strings.Join(got, "|") != "Harness (E2E)|Lint|Run Tests" {
t.Errorf("splitCSV = %v", got)
}
if got := splitCSV(" "); strings.Join(got, "|") != "CI" {
t.Errorf("splitCSV empty should default to CI, got %v", got)
}
// The triage workflow must embed the array so workflow_run watches all of them.
cfg := testCfg
cfg.CIWorkflowsYAML = `["Harness (E2E)", "Lint", "Run Tests"]`
s := mustRender(t, "templates/loop-triage.yml.tmpl", cfg)
if !strings.Contains(s, `workflows: ["Harness (E2E)", "Lint", "Run Tests"]`) {
t.Errorf("triage workflow does not watch the CI workflow list:\n%s", s)
}
}
func TestParseRoles(t *testing.T) {
if got, err := parseRoles("all"); err != nil || len(got) != len(allRoles) {
t.Errorf("all => %v, %v", got, err)
}
// Canonical order preserved regardless of input order.
got, err := parseRoles("release,planner")
if err != nil {
t.Fatal(err)
}
if strings.Join(got, ",") != "planner,release" {
t.Errorf("expected canonical order planner,release; got %v", got)
}
if _, err := parseRoles("bogus"); err == nil {
t.Error("expected error for unknown role")
}
if _, err := parseRoles(""); err == nil {
t.Error("expected error for empty roles")
}
}
func TestVerifyMissingPromptFails(t *testing.T) {
dir := t.TempDir()
if err := scaffold(dir, testCfg, []string{"planner", "builder", "triage"}, testCrons, false); err != nil {
t.Fatalf("scaffold: %v", err)
}
// Delete a prompt → verify must flag it.
if err := os.Remove(filepath.Join(dir, promptDir, "builder.md")); err != nil {
t.Fatal(err)
}
_, missing := verifyState(dir)
found := false
for _, m := range missing {
if strings.Contains(m, "builder.md") {
found = true
}
}
if !found {
t.Errorf("expected verify to flag the missing builder prompt; got %v", missing)
}
}
func mustRender(t *testing.T, tmplName string, cfg config) string {
t.Helper()
b, err := render(tmplName, cfg)
if err != nil {
t.Fatalf("render %s: %v", tmplName, err)
}
return string(b)
}
func mustWrite(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
+23
View File
@@ -0,0 +1,23 @@
# North Star
> **Edit this file.** It is the single source of direction the loop aligns every
> increment to. The planner ranks work against it; the builder builds toward it.
> Be concrete — vague direction produces vague increments.
## Mission
<One or two sentences: the problem this repository solves and who it's for.>
## Right now
<The current priority — what "better" means this month. The planner weights the
queue toward this.>
## Guardrails
- One concern per PR; small and reversible.
- The gate is green CI, not a human review — keep the test/lint suite strong,
because the loop is only as good as its evaluator.
- **Off-limits without a human** (surface as notes, never auto-merge): breaking
public API changes, brand/positioning/marketing copy, new dependencies,
architectural rewrites, product-default changes with broad behavioral impact.

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