Compare commits

...

382 Commits

Author SHA1 Message Date
dependabot[bot] 83738f68bd build(deps): bump github.com/anthropics/anthropic-sdk-go
Bumps [github.com/anthropics/anthropic-sdk-go](https://github.com/anthropics/anthropic-sdk-go) from 1.55.1 to 1.61.0.
- [Release notes](https://github.com/anthropics/anthropic-sdk-go/releases)
- [Changelog](https://github.com/anthropics/anthropic-sdk-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/anthropics/anthropic-sdk-go/compare/v1.55.1...v1.61.0)

---
updated-dependencies:
- dependency-name: github.com/anthropics/anthropic-sdk-go
  dependency-version: 1.61.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-30 17:13:50 +00:00
Guoguo 49183d7e8d fix: update Go and x/text for govulncheck (#3286) 2026-07-23 10:08:09 +08:00
Guoguo e14f8daecb Revert "docs: add PicoPaw banners to READMEs (#3096)" (#3285)
This reverts commit 083e68b49a.
2026-07-22 17:15:29 +08:00
Mauro 85dcfccad6 Merge pull request #3226 from ACMYuechen/fix/writefile-overwrite-guidance
fix(tools): stop write_file from coaching destructive overwrite (#3150)
2026-07-09 13:31:41 +02:00
Yue_chen 9a0efd7bab style(tools): wrap write_file guidance Sprintf for golines 2026-07-07 21:04:16 +08:00
Yue_chen 0132837d22 Merge branch 'main' into fix/writefile-overwrite-guidance 2026-07-07 20:46:53 +08:00
Yue_chen a680d8fcfa fix(tools): gate write_file overwrite guidance on available tools
#3150 reworded write_file's description, overwrite parameter, and guard
error to prefer append_file/edit_file. But those tools register via their
own enable flags and the per-agent allowlist, so a config exposing only
write_file was steered toward tools it does not have — a dead end in the
most restrictive setups.

WriteFileTool now tracks which non-destructive alternatives are available
and names only the ones present; when none are, the guard still blocks the
overwrite without pointing anywhere. The agent wiring registers the editors
before write_file and resolves availability via registry.HasRegistered,
which reflects both the enable flag and the allowlist.

Tests: conditional copy at the tool level (none/one/both) and agent wiring
across the enable-flag and allowlist dimensions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:40:27 +08:00
Mauro c87b154b61 Merge pull request #3227 from AayushGupta16/fix/anthropic-tool-use-function-fallback
fix(providers): resolve tool_use name/args from Function on reloaded history
2026-07-06 13:57:51 +02:00
AayushGupta16 994c0aea11 fix(providers): resolve tool_use name/args from Function on reloaded history
ToolCall.Name and .Arguments are json:"-" (runtime-only), so after chat
history round-trips through the session store only ToolCall.Function
survives. The anthropic-messages and anthropic (SDK) providers emitted
tool_use blocks from tc.Name alone, silently skipping every historical
tool call while still emitting the matching tool_result — orphaned
tool_results 400 at the API ("unexpected tool_use_id found in
tool_result blocks"), killing every turn on agents with tool history.

Fall back to Function.Name / json-parsed Function.Arguments (the same
pattern the bedrock and openai_compat providers already use), and cover
the deserialized-history shape with table tests in both providers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:26:34 -07:00
Mauro b6e6d25d75 Merge pull request #3189 from chengzhichao-xydt/codex/line-body-close
fix(line): explicitly ignore resp.Body.Close() errors in Send and classifySDKError
2026-07-05 09:35:40 +02:00
Yue_chen 8f891d5dd0 fix(tools): stop write_file from coaching destructive overwrite (#3150)
The overwrite guard error ("Set overwrite=true to replace") steered the
model to clobber files like MEMORY.md. Reword write_file's description,
overwrite param, and guard error to prefer append_file/edit_file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 14:13:31 +08:00
Mauro 0f4a997b47 Merge pull request #3224 from Ethan1918/fix/clear-routed-agent-session
fix(agent): clear routed agent session
2026-07-04 12:57:35 +02:00
Ethan1918 79c075fba0 fix(openai_compat): remove unused log import 2026-07-04 14:34:58 +08:00
Ethan1918 ffffb6a0cb refactor(agent): route /clear through ContextManager.Clear for all agents
Address review feedback: the routed-agent /clear path bypassed the
public ContextManager.Clear contract via an unexported hook. Restore
the single Clear call in the command path and resolve the session's
owning agent inside the built-in implementations instead:

- legacy: Clear resolves the owning agent via agentForSession instead
  of assuming the default agent
- seahorse: drop ClearContextStore; Clear wipes the engine state and
  the owning agent's session store
- command path: persist session scope metadata before Clear so
  ownership resolves even when /clear is the session's first message
- add coverage that a custom ContextManager receives Clear for routed
  agents

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 14:04:37 +08:00
Ethan1918 c4fb7a2001 fix(agent): clear routed agent session 2026-07-04 04:14:03 +08:00
Mauro 4c5adcd78e Merge pull request #3128 from chengzhichao-xydt/codex/web-body-close
fix(web): explicitly ignore resp.Body.Close() errors after io.ReadAll
2026-07-03 20:45:55 +02:00
Mauro cf24f32896 Merge pull request #3156 from loafoe/feat/session-token-usage
feat(pico): emit per-turn LLM token usage on finalized message
2026-07-03 09:14:36 +02:00
Mauro 4ccb6268a4 Merge pull request #3212 from sipeed/dependabot/npm_and_yarn/web/frontend/react-i18next-17.0.7
build(deps): bump react-i18next from 17.0.6 to 17.0.7 in /web/frontend
2026-07-02 23:50:18 +02:00
Mauro 1d9b1c444a Merge pull request #3214 from sipeed/dependabot/npm_and_yarn/web/frontend/shadcn-4.12.0
build(deps): bump shadcn from 4.7.0 to 4.12.0 in /web/frontend
2026-07-02 23:49:35 +02:00
Mauro 0aff1bd7ab Merge pull request #3215 from sipeed/dependabot/npm_and_yarn/web/frontend/typescript-eslint-8.62.1
build(deps-dev): bump typescript-eslint from 8.59.3 to 8.62.1 in /web/frontend
2026-07-02 23:49:10 +02:00
Mauro 1a6dd0efe5 Merge pull request #3216 from sipeed/dependabot/npm_and_yarn/web/frontend/vitejs/plugin-react-6.0.3
build(deps-dev): bump @vitejs/plugin-react from 6.0.1 to 6.0.3 in /web/frontend
2026-07-02 23:48:43 +02:00
Mauro 4b02293516 Merge pull request #3160 from danmobot/fix/launcher-setup-csrf
fix(auth): reject cross-site launcher setup requests
2026-07-02 23:46:41 +02:00
Mauro 883d43b37d Merge pull request #3063 from trufae/deltachan
feat: add deltachat gateway
2026-07-02 23:45:24 +02:00
Mauro 3b24a48a8c Merge pull request #3161 from danmobot/fix/exec-custom-allow-deny
fix(exec): keep deny patterns active for custom allow rules
2026-07-02 21:51:16 +02:00
Mauro 027226997f Merge pull request #3209 from sipeed/dependabot/go_modules/github.com/anthropics/anthropic-sdk-go-1.55.1
build(deps): bump github.com/anthropics/anthropic-sdk-go from 1.50.2 to 1.55.1
2026-07-02 21:44:00 +02:00
Mauro ce5274d179 Merge pull request #3210 from sipeed/dependabot/go_modules/golang.org/x/crypto-0.53.0
build(deps): bump golang.org/x/crypto from 0.51.0 to 0.53.0
2026-07-02 21:42:38 +02:00
Mauro 79ae6bf97f Merge pull request #3158 from danmobot/fix/sandbox-fs-windows-paths
test: cover sandbox fs Windows path handling
2026-07-02 21:39:45 +02:00
dependabot[bot] 93a58e057e build(deps-dev): bump @vitejs/plugin-react in /web/frontend
Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 6.0.1 to 6.0.3.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.3/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-02 17:21:01 +00:00
dependabot[bot] 70b9cb9ea0 build(deps-dev): bump typescript-eslint in /web/frontend
Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 8.59.3 to 8.62.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.62.1/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.62.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-02 17:17:28 +00:00
dependabot[bot] 8fd07bcacb build(deps): bump shadcn from 4.7.0 to 4.12.0 in /web/frontend
Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.7.0 to 4.12.0.
- [Release notes](https://github.com/shadcn-ui/ui/releases)
- [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md)
- [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.12.0/packages/shadcn)

---
updated-dependencies:
- dependency-name: shadcn
  dependency-version: 4.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-02 17:15:41 +00:00
dependabot[bot] e56ab1f16e build(deps): bump react-i18next from 17.0.6 to 17.0.7 in /web/frontend
Bumps [react-i18next](https://github.com/i18next/react-i18next) from 17.0.6 to 17.0.7.
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.6...v17.0.7)

---
updated-dependencies:
- dependency-name: react-i18next
  dependency-version: 17.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-02 17:14:49 +00:00
dependabot[bot] addaef78ad build(deps): bump golang.org/x/crypto from 0.51.0 to 0.53.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.51.0 to 0.53.0.
- [Commits](https://github.com/golang/crypto/compare/v0.51.0...v0.53.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.53.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-02 17:14:16 +00:00
dependabot[bot] ba881f8273 build(deps): bump github.com/anthropics/anthropic-sdk-go
Bumps [github.com/anthropics/anthropic-sdk-go](https://github.com/anthropics/anthropic-sdk-go) from 1.50.2 to 1.55.1.
- [Release notes](https://github.com/anthropics/anthropic-sdk-go/releases)
- [Changelog](https://github.com/anthropics/anthropic-sdk-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/anthropics/anthropic-sdk-go/compare/v1.50.2...v1.55.1)

---
updated-dependencies:
- dependency-name: github.com/anthropics/anthropic-sdk-go
  dependency-version: 1.55.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-02 17:14:13 +00:00
pancake 612e485d4e WIP: Initial support for deltachat gateway
Features:
- Support voice messages
- Optional crossposting
- Automatic account creation
- Custom avatar image
2026-07-02 15:23:35 +02:00
LC 2cf030d2fd fix(providers): surface friendly auth error messages (#3198) 2026-06-30 17:42:07 +08:00
程智超0668000959 9068fde274 fix: gofumpt formatting on line.go 2026-06-27 10:20:37 +08:00
程智超0668000959 ce4a6c9bba fix(line): explicitly ignore resp.Body.Close() errors in Send and classifySDKError 2026-06-27 09:19:13 +08:00
Mauro 52320f4875 Merge pull request #3181 from Alix-007/fix/gateway-startup-info-ok
fix(gateway): guard startup info assertions
2026-06-26 23:06:34 +02:00
Mauro c15aac21fe Merge pull request #3143 from lc6464/fix/isatap-ssrf-guard
fix(web): block private IPv4 embeds in ISATAP literals
2026-06-26 22:59:41 +02:00
Mauro 7ee4f41b7e Merge pull request #3187 from chengzhichao-xydt/codex/http-guard-test-body-close
test(utils): explicitly ignore resp.Body.Close() errors in tests
2026-06-26 18:11:24 +02:00
Mauro d65d592a40 Merge pull request #3188 from chengzhichao-xydt/codex/health-encode-errors
fix(health): explicitly ignore json.Encode errors in HTTP handler responses
2026-06-26 18:11:05 +02:00
Mauro 06830c9b19 Merge pull request #3186 from chengzhichao-xydt/codex/membench-body-close
fix(membench): explicitly ignore resp.Body.Close() error after io.ReadAll
2026-06-26 18:10:36 +02:00
Mauro 9bc8d15685 Merge pull request #3185 from chengzhichao-xydt/codex/updater-checksum-body-close
fix(updater): explicitly ignore resp2.Body.Close() error after io.ReadAll
2026-06-26 18:10:21 +02:00
Mauro 979f440af7 Merge pull request #3184 from chengzhichao-xydt/codex/ws-body-close
fix(channels): explicitly ignore resp.Body.Close() errors in websocket dial cleanup
2026-06-26 18:10:06 +02:00
Mauro 377c25e7c5 Merge pull request #3183 from chengzhichao-xydt/codex/onebot-body-close
fix(onebot): explicitly ignore resp.Body.Close() error after websocket dial
2026-06-26 18:09:04 +02:00
程智超0668000959 2cdfadaa8b test(utils): explicitly ignore resp.Body.Close() errors in tests 2026-06-26 23:03:32 +08:00
程智超0668000959 0eb721fff7 fix(health): explicitly ignore json.Encode errors in HTTP handler responses 2026-06-26 22:57:48 +08:00
程智超0668000959 14c9a1f1f8 fix(membench): explicitly ignore resp.Body.Close() error after io.ReadAll 2026-06-26 22:51:51 +08:00
程智超0668000959 500ce72436 fix(onebot): explicitly ignore resp.Body.Close() error after websocket dial 2026-06-26 22:42:43 +08:00
程智超0668000959 583d9e1e14 fix(updater): explicitly ignore resp2.Body.Close() error after io.ReadAll 2026-06-26 22:15:29 +08:00
程智超0668000959 1bd2e84527 fix(channels): explicitly ignore resp.Body.Close() errors in websocket dial cleanup 2026-06-26 22:15:03 +08:00
Alix-007 36d72a0112 fix(gateway): guard startup info assertions 2026-06-26 16:11:54 +08:00
Mauro 1260fd8e2f Merge pull request #3176 from sipeed/dependabot/go_modules/github.com/mymmrac/telego-1.10.0
build(deps): bump github.com/mymmrac/telego from 1.9.0 to 1.10.0
2026-06-26 08:43:46 +02:00
Mauro 54f1ab75e2 Merge pull request #3175 from sipeed/dependabot/go_modules/fyne.io/systray-1.12.2
build(deps): bump fyne.io/systray from 1.12.1 to 1.12.2
2026-06-26 08:43:02 +02:00
Mauro 7f90ce7a98 Merge pull request #3174 from sipeed/dependabot/go_modules/github.com/line/line-bot-sdk-go/v8-8.20.1
build(deps): bump github.com/line/line-bot-sdk-go/v8 from 8.20.0 to 8.20.1
2026-06-26 08:42:42 +02:00
Mauro 70b2364f39 Merge pull request #3173 from sipeed/dependabot/go_modules/modernc.org/sqlite-1.53.0
build(deps): bump modernc.org/sqlite from 1.51.0 to 1.53.0
2026-06-26 08:42:17 +02:00
Mauro 124c529bf1 Merge pull request #3172 from chengzhichao-xydt/codex/close-error-checks-clean
fix: explicitly ignore Close() errors in error paths and retry loops
2026-06-26 08:41:31 +02:00
Mauro 5feeb91c60 Merge pull request #3170 from chengzhichao-xydt/codex/base64-encoder-close-clean
fix(agent): close base64 encoder on io.Copy error path
2026-06-26 08:40:58 +02:00
dependabot[bot] 9287df415b build(deps): bump github.com/mymmrac/telego from 1.9.0 to 1.10.0
Bumps [github.com/mymmrac/telego](https://github.com/mymmrac/telego) from 1.9.0 to 1.10.0.
- [Release notes](https://github.com/mymmrac/telego/releases)
- [Commits](https://github.com/mymmrac/telego/compare/v1.9.0...v1.10.0)

---
updated-dependencies:
- dependency-name: github.com/mymmrac/telego
  dependency-version: 1.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-25 17:14:13 +00:00
dependabot[bot] ae81ae93cf build(deps): bump fyne.io/systray from 1.12.1 to 1.12.2
Bumps [fyne.io/systray](https://github.com/fyne-io/systray) from 1.12.1 to 1.12.2.
- [Changelog](https://github.com/fyne-io/systray/blob/master/CHANGELOG.md)
- [Commits](https://github.com/fyne-io/systray/compare/v1.12.1...v1.12.2)

---
updated-dependencies:
- dependency-name: fyne.io/systray
  dependency-version: 1.12.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-25 17:14:09 +00:00
dependabot[bot] e4bda8c4a1 build(deps): bump github.com/line/line-bot-sdk-go/v8
Bumps [github.com/line/line-bot-sdk-go/v8](https://github.com/line/line-bot-sdk-go) from 8.20.0 to 8.20.1.
- [Release notes](https://github.com/line/line-bot-sdk-go/releases)
- [Commits](https://github.com/line/line-bot-sdk-go/compare/v8.20.0...v8.20.1)

---
updated-dependencies:
- dependency-name: github.com/line/line-bot-sdk-go/v8
  dependency-version: 8.20.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-25 17:14:04 +00:00
dependabot[bot] 3400221527 build(deps): bump modernc.org/sqlite from 1.51.0 to 1.53.0
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.51.0 to 1.53.0.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.51.0...v1.53.0)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.53.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-25 17:13:40 +00:00
程智超0668000959 7ee4ee3b64 fix: correct indentation in shell.go and updater.go for gci linter 2026-06-25 16:11:34 +08:00
程智超0668000959 62a2b0015a fix: explicitly ignore Close() errors in error paths and retry loops 2026-06-25 15:38:41 +08:00
程智超0668000959 d6371fcb68 fix(agent): close base64 encoder on io.Copy error path
Previously when io.Copy to the base64 encoder failed, encoder.Close()
was skipped. This left the encoder's internal buffer unflushed.
Now always call Close() and handle both copy and close errors
explicitly.
2026-06-25 15:23:34 +08:00
程智超0668000959 c3aa8c088c fix(web): explicitly ignore resp.Body.Close() errors after io.ReadAll 2026-06-25 15:13:29 +08:00
Mauro dff16dbb91 Merge pull request #3169 from Alix-007/fix/evolution-heartbeat-coldpath
fix(evolution): skip cold path for heartbeat turns
2026-06-25 08:59:03 +02:00
Mauro 0a47fc8eb1 Merge pull request #3168 from Alix-007/fix/model-list-http-error-read
fix(model): handle error response read failures
2026-06-25 08:42:36 +02:00
Alix-007 f53e6e68e2 fix(model): avoid shadowing response error 2026-06-24 23:35:06 +08:00
Alix-007 288013ca0f fix(evolution): skip cold path for heartbeat turns 2026-06-24 23:29:46 +08:00
Alix-007 5e3fd4cd0f fix(model): handle error response read failures 2026-06-24 23:29:46 +08:00
Alix-007 540f81ff9e fix(openai_compat): use structured logger for native_search warning 2026-06-24 18:30:44 +08:00
danmobot acc2c5c6aa test(fs): cover root path separator normalization 2026-06-23 20:57:42 +01:00
danmobot ff247b63ac fix(exec): preserve additive custom allow behavior 2026-06-23 20:47:15 +01:00
danmobot 0c404869ae fix: restore openai compat log import 2026-06-23 20:41:28 +01:00
danmobot 263e940825 Merge remote-tracking branch 'upstream/main' into fix/launcher-setup-csrf 2026-06-23 20:38:16 +01:00
danmobot 9721c36e55 fix(exec): keep deny patterns active for custom allow rules 2026-06-23 02:53:14 +01:00
danmobot 1758eea948 test: cover launcher setup csrf guard 2026-06-23 02:24:53 +01:00
danmobot f5b2ce7482 fix: reject cross-site launcher setup requests 2026-06-23 02:23:06 +01:00
danmobot 46ffda264f fix: import log in openai compat provider 2026-06-22 23:42:27 +01:00
danmobot 88bda73db6 Merge remote-tracking branch 'upstream/main' into fix/sandbox-fs-windows-paths 2026-06-22 23:42:04 +01:00
Mauro a75b3d15bb Merge pull request #3053 from chengzhichao-xydt/codex/store-lock-type-assert
fix(evolution): add ok check for LoadOrStore type assertion in lockStoreFile
2026-06-23 00:04:42 +02:00
danmobot 29e019ec66 test: cover sandbox fs Windows path handling 2026-06-22 23:01:20 +01:00
Mauro eb8b6ca7c8 Merge pull request #3101 from sipeed/dependabot/npm_and_yarn/web/frontend/vite-8.0.16
build(deps-dev): bump vite from 8.0.13 to 8.0.16 in /web/frontend
2026-06-22 23:13:12 +02:00
Mauro ec9c6cde8e Merge pull request #3091 from chengzhichao-xydt/codex/provider-native-search-type-assert
fix(openai_compat): add ok check for native_search type assertion
2026-06-22 23:12:51 +02:00
dependabot[bot] 646c2db1e9 build(deps-dev): bump vite from 8.0.13 to 8.0.16 in /web/frontend
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.13 to 8.0.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-22 21:04:17 +00:00
Mauro dc46c05aac Merge pull request #3105 from sipeed/dependabot/npm_and_yarn/web/frontend/eslint-10.4.1
build(deps-dev): bump eslint from 10.2.1 to 10.4.1 in /web/frontend
2026-06-22 23:01:39 +02:00
Mauro 43c671abdc Merge pull request #3152 from phoeagon/main
add installation instructions to picoclaw skills search.
2026-06-22 22:59:12 +02:00
Andy Lo-A-Foe cc7b4ca86b fix(pico): deliver per-turn token usage to the streamer
Two bugs prevented the usage block from ever reaching the wire:

1. CallLLM read turnStateFromContext(ctx), but the raw ctx is not seeded
   with the turn state (only turnCtx is), so SetLastUsage/SetLastFinishReason
   were dropped — GetLastUsage() returned nil at finalize. Set them on the
   ts parameter directly, which is also what the streaming publisher reads.

2. The manager wraps the channel streamer in finalizeHookStreamer /
   splitMarkerStreamer, neither of which forwarded SetTurnUsage (it is not
   part of the bus.Streamer interface), so the type assertion in the
   publisher's Finalize failed silently. Mirror the existing SetModelName
   forwarding: add a turnUsageStreamer interface + setStreamerTurnUsage
   helper and SetTurnUsage methods on both wrappers (splitMarker also stores
   and re-applies usage to each freshly-begun part streamer).

Adds regression tests asserting both wrappers forward SetTurnUsage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:38:46 +02:00
Andy Lo-A-Foe 697e94fe8c feat(pico): emit real per-turn token usage on finalized message 2026-06-22 13:15:35 +02:00
Andy Lo-A-Foe 052c742fe7 feat(pico): add SetTurnUsage and usage payload helper 2026-06-22 13:07:48 +02:00
phoeagon a63b44ac74 add installation instructions to picoclaw skills search. 2026-06-21 14:15:27 -07:00
Mauro 287853ab58 Merge pull request #3144 from sipeed/dependabot/github_actions/actions/checkout-7
build(deps): bump actions/checkout from 6 to 7
2026-06-18 22:09:12 +02:00
Mauro 0d1ed99007 Merge pull request #3146 from sipeed/dependabot/go_modules/golang.org/x/term-0.44.0
build(deps): bump golang.org/x/term from 0.43.0 to 0.44.0
2026-06-18 22:08:40 +02:00
Mauro 70afab59e6 Merge pull request #3147 from sipeed/dependabot/go_modules/github.com/Azure/azure-sdk-for-go/sdk/azidentity-1.14.0
build(deps): bump github.com/Azure/azure-sdk-for-go/sdk/azidentity from 1.13.1 to 1.14.0
2026-06-18 22:08:22 +02:00
Mauro a3be5a6b25 Merge pull request #3149 from sipeed/dependabot/go_modules/github.com/anthropics/anthropic-sdk-go-1.50.2
build(deps): bump github.com/anthropics/anthropic-sdk-go from 1.46.0 to 1.50.2
2026-06-18 22:07:53 +02:00
Mauro 62206befe1 Merge pull request #3148 from sipeed/dependabot/go_modules/golang.org/x/sys-0.46.0
build(deps): bump golang.org/x/sys from 0.45.0 to 0.46.0
2026-06-18 22:07:28 +02:00
dependabot[bot] 38a1d823c3 build(deps): bump github.com/anthropics/anthropic-sdk-go
Bumps [github.com/anthropics/anthropic-sdk-go](https://github.com/anthropics/anthropic-sdk-go) from 1.46.0 to 1.50.2.
- [Release notes](https://github.com/anthropics/anthropic-sdk-go/releases)
- [Changelog](https://github.com/anthropics/anthropic-sdk-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/anthropics/anthropic-sdk-go/compare/v1.46.0...v1.50.2)

---
updated-dependencies:
- dependency-name: github.com/anthropics/anthropic-sdk-go
  dependency-version: 1.50.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 17:14:29 +00:00
dependabot[bot] 35db389025 build(deps): bump golang.org/x/sys from 0.45.0 to 0.46.0
Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.45.0 to 0.46.0.
- [Commits](https://github.com/golang/sys/compare/v0.45.0...v0.46.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sys
  dependency-version: 0.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 17:14:24 +00:00
dependabot[bot] aeab7b15a4 build(deps): bump github.com/Azure/azure-sdk-for-go/sdk/azidentity
Bumps [github.com/Azure/azure-sdk-for-go/sdk/azidentity](https://github.com/Azure/azure-sdk-for-go) from 1.13.1 to 1.14.0.
- [Release notes](https://github.com/Azure/azure-sdk-for-go/releases)
- [Commits](https://github.com/Azure/azure-sdk-for-go/compare/sdk/azidentity/v1.13.1...sdk/azcore/v1.14.0)

---
updated-dependencies:
- dependency-name: github.com/Azure/azure-sdk-for-go/sdk/azidentity
  dependency-version: 1.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 17:14:21 +00:00
dependabot[bot] 6d0ca80f21 build(deps): bump golang.org/x/term from 0.43.0 to 0.44.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.43.0 to 0.44.0.
- [Commits](https://github.com/golang/term/compare/v0.43.0...v0.44.0)

---
updated-dependencies:
- dependency-name: golang.org/x/term
  dependency-version: 0.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 17:14:11 +00:00
dependabot[bot] 4514d54ff5 build(deps): bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 17:13:51 +00:00
lc6464 d4adbf9418 fix(web): block private IPv4 embeds in ISATAP literals 2026-06-18 17:12:38 +08:00
Mauro 99291182e7 Merge pull request #3141 from jincheng-xydt/fix/3125-brave-search-logging
fix(web_search): add diagnostic logging for Brave empty results
2026-06-18 08:16:14 +02:00
Mauro 6c31f9ffdd Merge pull request #3136 from ZOOWH/fix/3111-gemini-thought-signature
fix(gemini): set both camelCase and snake_case thought_signature in tool call request body
2026-06-17 20:40:13 +02:00
Mauro e3464f4388 Merge pull request #2917 from PierreLeGuen/nearai-provider
feat(provider): add NEAR AI Cloud provider
2026-06-17 20:37:38 +02:00
徐闻涵0668001344 08192ddf1b style(test): split long t.Fatalf line to satisfy golines 120-char limit 2026-06-17 17:49:52 +08:00
肆月 7b5fe01187 fix(web): update sogou search regex to match new HTML structure (#3139)
- reSogouTitle: make class attribute quotes optional (class="?resultLink"?)
- reSogouSnippet: allow additional CSS classes after clamp (clamp\d*[^"]*)

Sogou WAP search page changed its HTML format:
- class=resultLink -> class="resultLink" (added quotes)
- class="clamp3" -> class="clamp2 fz-mid click-sugg-content" (extra classes)

Fixes web_search tool failing to parse sogou search results.
2026-06-17 17:02:54 +08:00
lc6464 1420875921 fix(onebot): tighten inbound media download handling 2026-06-17 17:00:24 +08:00
lc6464 234bd03018 fix(onebot): block private inbound media fetches 2026-06-17 17:00:24 +08:00
徐金城0668000897 b2fbe83565 fix(web_search): add diagnostic logging for Brave empty results
When the Brave Search API returns HTTP 200 with zero results, log a
warning with the response body preview to help diagnose silent failures.
This addresses cases where the API response format has changed or a
non-standard error response is misinterpreted as a successful empty
result, leaving the LLM with a misleading "No results" message.

Previously, empty results were silently returned to the LLM as "No
results for: <query>", making it impossible to distinguish between
genuinely empty results and API format mismatches or silent errors.

Closes #3125
2026-06-17 16:23:25 +08:00
Mauro a16a1e1535 Merge pull request #3137 from jp39/cron-command-remotes
feat: allow configured remote cron commands
2026-06-16 22:16:05 +02:00
Mauro 910bfbe6a4 Merge pull request #3120 from carlosprados/feat/register-channel-settings
feat(config): add RegisterChannelSettings hook for out-of-tree channels
2026-06-16 22:09:00 +02:00
Mauro 03b022c2ac Merge pull request #3127 from chengzhichao-xydt/codex/filesystem-dirfile-close
fix: explicitly ignore Close() errors on directory file descriptors
2026-06-16 22:08:31 +02:00
Mauro cc4055b6d4 Merge pull request #3129 from chengzhichao-xydt/codex/tts-close-error
fix(tts): explicitly ignore file.Close() error in write error path
2026-06-16 22:08:10 +02:00
Mauro 86221d470b Merge pull request #3135 from ZOOWH/fix/3110-telegram-forum-thread-id
fix(telegram): use compositeChatID in InboundContext.ChatID for forum topics
2026-06-16 22:06:31 +02:00
Mauro e720be0dd7 Merge pull request #3132 from SiYue-ZO/fix/goroutine-recover
fix: add panic recovery to core-path goroutines
2026-06-16 22:05:31 +02:00
Mauro c9f5872e02 Merge pull request #3130 from chengzhichao-xydt/codex/seahorse-marshal-errors
fix(seahorse): handle json.Marshal errors in grep and expand tools
2026-06-16 22:04:45 +02:00
jp39 0c1e6b5279 docs: warn about cron remote wildcard
Document that command_allowed_remotes = ["*"] is potentially dangerous
because any remote channel that can talk to PicoClaw can schedule shell
commands. Clarify that it should only be used when all enabled remote
channels and chats are trusted.
2026-06-16 17:26:45 +02:00
jp39 981ab3affe fix: scope remote cron command access
Allow allowlisted remote channels to list, get, and update their own
command cron jobs while preserving exact channel/chat ownership checks.

Apply the same access control to remove, enable, and disable so remote
callers cannot mutate jobs outside their channel/chat scope. Keep wildcard
remote allowlist behavior constrained by exact job ownership.

Add regression tests for allowlisted, non-allowlisted, wildcard, internal,
and non-command reminder access paths.
2026-06-16 17:26:45 +02:00
jp39 3056a540bf feat: allow configured remote cron commands
Add tools.cron.command_allowed_remotes so remote channels can be
explicitly allowlisted for command-executing cron jobs. Entries support
channel names, channel:chat_id pairs, and a literal * to allow every
non-empty channel while preserving the default deny posture.

The existing allow_command and command_confirm checks still apply after the
channel gate. Update tests, config examples, and cron documentation for the
new option.
2026-06-16 17:26:45 +02:00
徐闻涵0668001344 5b41f18c3d fix(gemini): set both camelCase and snake_case thought_signature in tool call request body
The Gemini HTTP API provider only set thoughtSignature (camelCase) when
building tool call request bodies. Gemini 2.5 models accept camelCase,
but Gemini 3.5 Flash Agentic reasoning requires thought_signature
(snake_case) — matching the format it returns in API responses. When
only the camelCase variant was sent, Gemini 3.5 Flash rejected the
request with a 400 Bad Request for missing thought_signature.

The fix sets both ThoughtSignature and ThoughtSignatureSnake in the
geminiPart struct, matching the pattern already used in
antigravity_provider.go. This ensures compatibility with both Gemini
2.x (camelCase) and Gemini 3.x Agentic (snake_case) models.

Closes #3111
2026-06-16 20:51:59 +08:00
徐闻涵0668001344 7019143c8e fix(telegram): use compositeChatID in InboundContext.ChatID for forum topics
When handling inbound Telegram messages from forum topics, InboundContext.ChatID was set to the plain chat ID (without thread). This caused outbound messages to lose the thread routing, sending replies to the General topic instead of the specific forum topic — even though typing indicators correctly targeted the right thread because StartTyping received the compositeChatID.

The fix sets InboundContext.ChatID to compositeChatID (chatID/threadID format) for forum messages, matching the format already used for session routing and typing indicators. TopicID is still preserved as a redundant fallback for any path that strips the composite format.

Closes #3110
2026-06-16 20:43:37 +08:00
Guoguo 083e68b49a docs: add PicoPaw banners to READMEs (#3096) 2026-06-16 10:17:21 +08:00
SiYue-ZO fcc2882ffa fix: resolve golines lint issue in subscription.go
Split long log.Printf lines to comply with 120-char line length limit.
2026-06-16 00:53:18 +08:00
SiYue-ZO b292defd95 fix: add panic recovery to core-path goroutines
Add defer-recover to 11 goroutines across 4 files to prevent
ungoroutine panics from crashing the entire process:

- pkg/tools/toolloop.go: parallel tool execution
- pkg/channels/manager.go: HTTP server (x2), channel registration
- pkg/events/subscription.go: concurrent dispatch, timeout handler,
  watchContext
- pkg/tools/shell.go: cmd.Wait, PTY cmd.Wait, PTY read, pipe read

Key design decisions:
- Recover handlers send fallback values to channels (shell done,
  subscription done) to prevent deadlocks when the producer panics
- PTY cmd.Wait sets session.Status='error' on panic for consistency
- toolloop sets ErrorResult on panic so the LLM gets a meaningful
  response instead of a nil result
- subscription.go uses log.Printf to match existing invokeHandler style
- Other files use project logger (ErrorCF) with stack traces

Refs: FIX-PLAN-0.3.0 #2
2026-06-15 23:06:11 +08:00
程智超0668000959 ed55715b6b fix(seahorse): handle json.Marshal errors in grep and expand tools 2026-06-15 20:31:34 +08:00
程智超0668000959 2f8c462366 fix(tts): explicitly ignore file.Close() error in write error path 2026-06-15 20:30:09 +08:00
程智超0668000959 d2156a982c fix(fileutil): explicitly ignore Close() error on directory file descriptor 2026-06-15 20:25:04 +08:00
lc6464 c1ff5aa6f4 chore: remove unused chat send hint translations 2026-06-15 16:26:22 +08:00
lc6464 dc746fbe66 refactor: soften chat composer hint 2026-06-15 16:26:22 +08:00
lc6464 890f4d6bcb feat: add shift-enter hint below chat composer 2026-06-15 16:26:22 +08:00
LC 42ece2aceb fix(web): improve launcher allowlist bypass diagnostics (#3126) 2026-06-15 16:16:25 +08:00
Mauro 13a38bd1c4 Merge pull request #2904 from SiYue-ZO/feature/fix-agent-loop-stability
Fix agent loop reload and panic cleanup stability
2026-06-14 21:22:53 +02:00
Mauro 7b2e0ccfc7 Merge pull request #3124 from chengzhichao-xydt/codex/tts-readall-error
fix(tts): handle io.ReadAll error in error response path
2026-06-14 21:22:01 +02:00
Mauro 5e415ed210 Merge pull request #3123 from chengzhichao-xydt/codex/filesystem-dirfile-close
fix(filesystem): explicitly ignore Close() error on directory file descriptor
2026-06-14 21:21:46 +02:00
Mauro 647543bd26 Merge pull request #3122 from chengzhichao-xydt/codex/evolution-store-close
fix(evolution): capture Close() error on write file in appendJSONLRecords
2026-06-14 21:21:30 +02:00
Mauro 73738f1cd3 Merge pull request #3121 from chengzhichao-xydt/codex/openai-stream-logger
refactor(openai_compat): replace log.Printf with structured logger
2026-06-14 21:18:40 +02:00
SiYue-ZO 8290c72d44 fix(agent): replace WaitGroup with Cond-based counter and conditionalize panic cleanup
Two concurrency bugs identified in PR #2904 review:

1. Replace sync.WaitGroup with sync.Cond-based activeReqCount to avoid
   the "WaitGroup is reused before previous Wait has returned" panic that
   occurs when Add(1) races with a goroutine-launched Wait().

2. Make panic cleanup conditional: when runTurn panics, only delete the
   session's activeTurnStates entry if it still points to our placeholder.
   Previously, an unconditional delete could wipe a new message's slot
   claimed between the panic and the deferred cleanup.
2026-06-15 01:32:13 +08:00
程智超0668000959 e567f1faa1 fix(tts): handle io.ReadAll error in error response path 2026-06-14 23:46:13 +08:00
程智超0668000959 df753aaebf fix(filesystem): explicitly ignore Close() error on directory file descriptor 2026-06-14 23:45:48 +08:00
程智超0668000959 7d71a4d93f fix(evolution): capture Close() error on write file in appendJSONLRecords 2026-06-14 23:42:44 +08:00
程智超0668000959 97039b259e refactor(openai_compat): replace log.Printf with structured logger 2026-06-14 23:41:48 +08:00
Carlos Prados 9eadffda1e test(config): cover RegisterChannelSettings hook
Unit tests prove an out-of-tree channel type becomes valid and decodable after
RegisterChannelSettings, including the full InitChannelList -> GetDecoded path that
previously failed with "unknown type".
2026-06-14 12:07:54 +02:00
Carlos Prados e55ddf2e24 feat(config): add RegisterChannelSettings hook for out-of-tree channels
Expose a public registration hook so external packages that register a channel
factory via channels.RegisterFactory can also register the channel's settings
struct prototype. Without this, InitChannelList rejects any channel type absent
from the private channelSettingsFactory map, making out-of-tree channels
impossible without forking. The map access is now guarded by a RWMutex.

This is additive and changes no existing behavior.
2026-06-14 11:52:32 +02:00
Mauro cf67dd3851 Merge pull request #3065 from chengzhichao-xydt/codex/short-engine-close-errors
fix(seahorse): explicitly ignore Close() errors on PRAGMA/migration failure paths
2026-06-13 18:19:35 +02:00
Mauro f091c04ca9 Merge pull request #3066 from chengzhichao-xydt/codex/tmpfile-close-errors
fix: explicitly ignore Close() errors on temp file write/sync failure paths
2026-06-13 18:18:47 +02:00
Mauro 0bfb13c0df Merge pull request #3119 from not-the-author/fix/tts-openrouter-fallback-and-docs
fix(tts): support OpenRouter voice overrides and fallback
2026-06-13 18:15:30 +02:00
Mauro 15196b9cc9 Merge pull request #3117 from not-the-author/codex/fix-vision-routing-and-onboard-embed
fix(agent): route media turns to image models
2026-06-13 18:03:40 +02:00
not-the-author d44fa890e4 fix lint 2026-06-13 16:07:45 +02:00
not-the-author f087d8358b fix lint 2026-06-13 15:31:08 +02:00
not-the-author c180590799 fix(tts): support OpenRouter voice overrides and fallback 2026-06-13 15:07:21 +02:00
not-the-author f3d4555bc9 fix(agent): keep media routing scoped to current turn 2026-06-13 11:38:08 +02:00
not-the-author adb89a16b9 fix(agent): limit media routing to the active turn 2026-06-13 00:25:45 +02:00
not-the-author 58926f76b9 fix(agent): route resolved user attachments to image models 2026-06-12 23:11:42 +02:00
not-the-author 9fd0dbda96 fix(agent): route media turns to image models and embed onboard workspace 2026-06-12 22:33:51 +02:00
Mauro c362114cf1 Merge pull request #3113 from chengzhichao-xydt/codex/manager-channel-marshal-errors
fix(channels): check json marshal/unmarshal errors in toChannelHashes
2026-06-12 13:17:00 +02:00
Mauro d8c0b783a8 Merge pull request #3112 from chengzhichao-xydt/codex/toolloop-marshal-error-check
fix(tools): handle json.Marshal error in toolloop tool call arguments
2026-06-12 13:16:33 +02:00
Pierre LE GUEN 7de3d1f7e3 feat(provider): add NEAR AI Cloud provider 2026-06-12 10:18:34 +00:00
程智超0668000959 7338df2cfb fix(channels): check json marshal/unmarshal errors in toChannelHashes
Replace silently discarded json.Marshal and json.Unmarshal errors with
explicit checks. If serialization fails, log a warning and either
return early (for the config-level marshal/unmarshal) or skip the
channel (for per-channel marshal). This prevents silent data loss
when channel configuration contains unexpected types.
2026-06-12 14:18:28 +08:00
程智超0668000959 1719067aca fix(tools): handle json.Marshal error in toolloop tool call arguments 2026-06-12 14:16:18 +08:00
程智超0668000959 d26e4eca7d chore(openai_compat): fix golines formatting for CI linter 2026-06-12 13:44:34 +08:00
程智超0668000959 bbdf746bcb fix(evolution): use CompareAndSwap for atomic lockStoreFile repair
Replace the Delete+LoadOrStore pattern with CompareAndSwap to
atomically swap in a fresh mutex when a corrupted entry is detected.

Changes:
- Use a retry loop with CAS instead of Delete+LoadOrStore, avoiding
  the race where two goroutines could create different mutexes
- Add nil check: a nil *sync.Mutex passes the type assertion but
  panics on Lock() — now handled by the CAS recovery path
- Use continue-loop retry instead of unchecked second assertion

CompareAndSwap guarantees that when multiple goroutines detect a
corrupted entry, they converge on the same replacement mutex.
2026-06-12 11:25:14 +08:00
程智超0668000959 409cc051e7 fix(openai_compat): log warning instead of silently discarding native_search ok check
Replace the no-op _ = ok with a warning log when native_search has an
unexpected type. When the type assertion fails, nativeSearch already
defaults to false, which is conservative — but the caller should know
their option was malformed.
2026-06-12 11:23:13 +08:00
Mauro 413d37494b Merge pull request #2957 from loafoe/fix/tool-calls-filtering
fix(channels): prevent tool_calls from being dropped during streaming
2026-06-11 21:45:59 +02:00
Mauro d153952c15 Merge pull request #3106 from sipeed/dependabot/go_modules/github.com/aws/aws-sdk-go-v2/config-1.32.25
build(deps): bump github.com/aws/aws-sdk-go-v2/config from 1.32.17 to 1.32.25
2026-06-11 19:48:05 +02:00
dependabot[bot] 091c374348 build(deps): bump github.com/aws/aws-sdk-go-v2/config
Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.32.17 to 1.32.25.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.32.17...config/v1.32.25)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.32.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-11 17:27:06 +00:00
Mauro 0994d48e77 Merge pull request #3099 from sipeed/dependabot/go_modules/golang.org/x/sync-0.21.0
build(deps): bump golang.org/x/sync from 0.20.0 to 0.21.0
2026-06-11 19:24:22 +02:00
Mauro 729935ef1a Merge pull request #3098 from sipeed/dependabot/go_modules/github.com/modelcontextprotocol/go-sdk-1.6.1
build(deps): bump github.com/modelcontextprotocol/go-sdk from 1.5.0 to 1.6.1
2026-06-11 19:23:55 +02:00
Mauro 293fb69439 Merge pull request #3102 from sipeed/dependabot/go_modules/github.com/aws/aws-sdk-go-v2-1.42.0
build(deps): bump github.com/aws/aws-sdk-go-v2 from 1.41.11 to 1.42.0
2026-06-11 19:23:26 +02:00
dependabot[bot] c5a98a5f47 build(deps-dev): bump eslint from 10.2.1 to 10.4.1 in /web/frontend
Bumps [eslint](https://github.com/eslint/eslint) from 10.2.1 to 10.4.1.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.2.1...v10.4.1)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-11 17:16:28 +00:00
dependabot[bot] f30c611327 build(deps): bump github.com/aws/aws-sdk-go-v2 from 1.41.11 to 1.42.0
Bumps [github.com/aws/aws-sdk-go-v2](https://github.com/aws/aws-sdk-go-v2) from 1.41.11 to 1.42.0.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/v1.41.11...v1.42.0)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2
  dependency-version: 1.42.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-11 17:15:33 +00:00
dependabot[bot] 23b52ebef5 build(deps): bump golang.org/x/sync from 0.20.0 to 0.21.0
Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.20.0 to 0.21.0.
- [Commits](https://github.com/golang/sync/compare/v0.20.0...v0.21.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sync
  dependency-version: 0.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-11 17:14:11 +00:00
dependabot[bot] 0d4b06f088 build(deps): bump github.com/modelcontextprotocol/go-sdk
Bumps [github.com/modelcontextprotocol/go-sdk](https://github.com/modelcontextprotocol/go-sdk) from 1.5.0 to 1.6.1.
- [Release notes](https://github.com/modelcontextprotocol/go-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/go-sdk/compare/v1.5.0...v1.6.1)

---
updated-dependencies:
- dependency-name: github.com/modelcontextprotocol/go-sdk
  dependency-version: 1.6.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-11 17:14:07 +00:00
Mauro fa6ed714c4 Merge pull request #3060 from chengzhichao-xydt/codex/error-wrap-and-marshal
fix: use %w for error wrapping and handle json.MarshalIndent error
2026-06-11 18:36:55 +02:00
Mauro f8472d6f27 Merge pull request #3067 from SiYue-ZO/fix/session-dm-scope-save
fix: add DmScope field to SessionConfig to persist dm_scope setting
2026-06-11 18:34:02 +02:00
Mauro 44fdf9a20b Merge pull request #3087 from jp39/fix/exec-relative-workspace-paths
fix(tools): allow workspace relative exec paths
2026-06-11 18:33:13 +02:00
jp39 17e4720203 fix(tools): allow workspace relative exec paths 2026-06-11 16:38:16 +02:00
SiYue-ZO ef002d9a5d fix: ensure dm_scope and dimensions stay in sync across all config paths
The reviewer identified two bugs in the original PR:

1. PATCH /api/config leaves session.dimensions stale: LoadConfig()
   derives dimensions from the old dm_scope, and the merge carries
   those stale dimensions forward. ApplyDmScope() then exits early
   because dimensions is already populated, causing a mismatch between
   dm_scope (new) and dimensions (old).

2. Legacy/default configs omit dm_scope in GET response: configs with
   explicit dimensions but no dm_scope (including DefaultConfig) return
   no dm_scope field, causing the frontend to fall back to its default
   ('per-channel-peer'), which may not match the actual dimensions.

Fix:
- Add DeriveDmScope() to reverse-map known dimensions arrays to
  dm_scope when dm_scope is empty.
- Call it in LoadConfig(), PUT handler, PATCH handler, and
  ResetToDefaults() for consistent normalization.
- In PATCH handler, clear stale dimensions from the merge result when
  the patch contains session.dm_scope but not session.dimensions,
  allowing ApplyDmScope() to re-derive from the new scope.
- Add comprehensive unit tests for DeriveDmScope() and scope
  transition scenarios.
2026-06-11 16:12:48 +08:00
lc6464 017601354b fix(web): harden trusted proxy client IP parsing 2026-06-11 15:16:05 +08:00
lc6464 52ab6c4694 feat(web): harden launcher access control 2026-06-11 15:16:05 +08:00
Mauro f8462855d8 Merge pull request #3095 from chengzhichao-xydt/codex/new-pr
fix(utils): add ok checks for http.Transport type assertions in CreateHTTPClient
2026-06-11 09:13:29 +02:00
肆月 2861fd90ab fix(launcher): hide console flashes in all Windows child processes (#3061)
* fix(launcher): hide console flashes in all Windows child processes

PR #2654 only applied HideWindow to child processes in gateway.go (powershell, tasklist, ps). Several other files still use exec.Command directly, causing visible console windows on Windows.

- startup.go: reg query/add/delete for autostart registry

- version.go: picoclaw version subcommand

- runtime.go: rundll32 for browser launch

- onboard.go: picoclaw onboard subcommand

Add launcherExecCommand to the utils package (matching the api package pattern) and replace all bare exec.Command calls on Windows paths.

* refactor: consolidate launcherExecCommand into utils package

Export LauncherExecCommand and ApplyLauncherProcAttrs from the utils
package as the single source of truth. The api package now imports
and delegates to these exported functions, eliminating code duplication.

Addresses review feedback from imguoguo on PR #3061.
2026-06-11 15:10:56 +08:00
LC 40fe1b0a2d fix(gitignore): normalize text encoding (#3084) 2026-06-11 15:06:12 +08:00
SiYue-ZO a7208983d0 feat(bus): add backpressure drop budget for audio streams only 2026-06-11 13:55:56 +08:00
程智超0668000959 9955155389 fix(utils): add ok checks for http.Transport type assertions in CreateHTTPClient 2026-06-11 09:45:14 +08:00
Mauro d955d5bbf3 Merge pull request #3089 from cs8425/fix-win-os-root-api
fix os.Root api on windows issue
2026-06-11 00:42:08 +02:00
Mauro 2efbe5d560 Merge pull request #3085 from ACMYuechen/fix/tools-ssrf-198.18
fix(tools): block 198.18.0.0/15 in SSRF guard
2026-06-11 00:16:24 +02:00
Mauro bc6179917c Merge pull request #3043 from chengzhichao-xydt/codex/atoi-gateway-json
fix: check strconv.Atoi and json.Unmarshal errors
2026-06-10 23:58:23 +02:00
程智超0668000959 351ecf0182 fix(openai_compat): add ok check for native_search type assertion 2026-06-10 17:36:53 +08:00
程智超0668000959 f5add27d39 fix(evolution): add ok check for LoadOrStore type assertion in lockStoreFile 2026-06-10 14:05:12 +08:00
程智超0668000959 8a2c67fe70 fix: check strconv.Atoi and json.Unmarshal errors
short_retrieval.go: Check Atoi error even though regex ensures numeric input. gateway.go: Log warning when gateway config JSON is malformed instead of silently using defaults.
2026-06-10 14:03:53 +08:00
cs8425 355e83e07f fix os.Root api on windows issue 2026-06-10 12:31:35 +08:00
Mauro b9a8fad6fa Merge pull request #3064 from chengzhichao-xydt/codex/migration-model-name-type-assert
fix(config): add ok check for type assertion in migration model name indexing
2026-06-09 21:18:53 +02:00
Guoguo 0ab6924978 docs: update wechat qrcode (#3086)
Signed-off-by: Guoguo <i@qwq.trade>
2026-06-09 20:34:10 +08:00
Yue_chen 2ecdb893d5 fix(tools): block 198.18.0.0/15 in SSRF guard
RFC 2544 benchmark addresses (198.18.0.0/15) are not globally routable
but were missing from the isPrivateOrRestrictedIP blocklist, allowing
SSRF bypasses via literal IPv4.

Fixes #3077
2026-06-09 19:01:42 +08:00
SiYue-ZO 921d753cc0 fix: wire dm_scope into runtime session isolation dimensions
The dm_scope field was stored in config but never translated into the
dimensions array that the routing layer actually consumes. This meant
changing the session isolation scope in the UI had no effect at runtime.

Add ApplyDmScope() to SessionConfig which maps the user-facing dm_scope
values (per-channel-peer, per-channel, per-peer, global) to the
corresponding dimension arrays. Call it in LoadConfig post-processing
and in both the PATCH and PUT API handlers.

Includes table-driven tests covering all dm_scope values and the
precedence rule (explicit dimensions > derived from dm_scope).
2026-06-09 11:03:33 +08:00
SiYue-ZO 0bbd8f081e fix: add DmScope field to SessionConfig to persist dm_scope setting
The frontend sends dm_scope as part of the session config, but the
backend SessionConfig struct lacked the corresponding field. Go's
encoding/json silently discards unknown fields, so the value was lost
on every PATCH request. Additionally, MarshalJSON only emitted the
session block when Dimensions or IdentityLinks were set, so even a
stored dm_scope would not appear in GET responses.

- Add DmScope string field with json tag 'dm_scope' to SessionConfig
- Update MarshalJSON condition to include session when DmScope is set
2026-06-09 10:48:17 +08:00
程智超0668000959 6b004f6a5f fix: explicitly ignore Close() errors on temp file write/sync failure paths 2026-06-09 09:52:48 +08:00
程智超0668000959 3daeec96ab fix(seahorse): explicitly ignore Close() errors on PRAGMA/migration failure paths 2026-06-09 09:48:52 +08:00
程智超0668000959 fc90a5af23 fix(config): add ok check for type assertion in migration model name indexing 2026-06-09 09:41:14 +08:00
程智超0668000959 e2112e627c fix: use %w for error wrapping and handle json.MarshalIndent error 2026-06-09 09:04:56 +08:00
Mauro 46b29a0ae9 Merge pull request #3062 from trufae/health-ready
fix: health check always returning not ready
2026-06-08 19:04:09 +02:00
Mauro 13bf650807 Merge pull request #3058 from chengzhichao-xydt/codex/webfetch-allowed-host-type-assert
fix(webfetch): add ok check for type assertion in isAllowedFirstHopHost
2026-06-08 18:51:42 +02:00
Mauro 0f86d9aacb Merge pull request #3057 from chengzhichao-xydt/codex/subagent-spawn-type-assertions
fix(tools): add ok checks for type assertions in subagent and spawn tools
2026-06-08 18:51:14 +02:00
Mauro c215a4caaf Merge pull request #3056 from chengzhichao-xydt/codex/base-tool-type-assertions
fix(tools): add ok checks for context value type assertions in base.go
2026-06-08 18:50:27 +02:00
Mauro 5b9f9c85a9 Merge pull request #3055 from chengzhichao-xydt/codex/context-getwd-error
fix(agent): handle os.Getwd error in NewContextBuilder
2026-06-08 18:48:09 +02:00
Mauro b40a1d92cc Merge pull request #3052 from wzg-gie/fix/telegram-location-message
fix: handle Telegram location messages
2026-06-08 18:40:54 +02:00
pancake fac5603daf fix: health check always returning not ready 2026-06-08 12:14:06 +02:00
程智超0668000959 a4e8fe953e fix(webfetch): add ok check for type assertion in isAllowedFirstHopHost 2026-06-08 17:25:44 +08:00
程智超0668000959 77017eb57d fix(tools): add ok checks for type assertions in subagent and spawn tools 2026-06-08 17:25:19 +08:00
程智超0668000959 92a647bfcf fix(tools): add ok checks for context value type assertions in base.go 2026-06-08 17:24:50 +08:00
程智超0668000959 8a246c2282 fix(agent): handle os.Getwd error in NewContextBuilder without behavior regression 2026-06-08 16:52:00 +08:00
2023478 3bba6338ca fix: handle Telegram location messages 2026-06-08 15:53:42 +08:00
Mauro 12c36572a5 Merge pull request #3051 from chengzhichao-xydt/codex/error-wrap-percent-w
fix: use %w instead of %v for error wrapping in channels and mcp
2026-06-08 09:14:02 +02:00
Mauro 890780b924 Merge pull request #3050 from chengzhichao-xydt/codex/use-logger-for-warnings
refactor: replace log.Printf/fmt.Printf with structured logger
2026-06-08 09:12:35 +02:00
程智超0668000959 1ab442b12c refactor: replace log.Printf/fmt.Printf with structured logger
Replace raw log.Printf and fmt.Printf calls in pkg/state, pkg/agent, and pkg/tools with structured logger calls (WarnCF/InfoCF). This ensures warnings and info messages are routed through the configured logging infrastructure instead of raw stderr/stdout.
2026-06-08 09:18:02 +08:00
程智超0668000959 3f435c5e56 fix: use %w instead of %v for error wrapping
errutil.go: Change %v to %w in ClassifySendError and ClassifyNetError so callers can use errors.Is/errors.As on the underlying HTTP/network error.

isolated_command_transport.go: Change %v to %w in Close() and Write() error paths for the same reason.
2026-06-08 09:10:14 +08:00
Mauro 875cf4a2d4 Merge pull request #3042 from chengzhichao-xydt/codex/evolution-getwd-errors
fix: handle os.Getwd() error in evolution skills_recall and drafts
2026-06-08 00:13:32 +02:00
Mauro 5e7b84f429 Merge pull request #3046 from chengzhichao-xydt/codex/startup-info-type-assert
fix(agent): add ok checks for startup info type assertions
2026-06-08 00:11:03 +02:00
Mauro 1b3e887fc6 Merge pull request #3037 from jp39/kagi-native-web-search
Add native Kagi web search provider
2026-06-07 22:51:35 +02:00
Mauro d627dc8b57 Merge pull request #2902 from puneetdixit200/docs/termux-android-guide
docs: add Android Termux guide
2026-06-07 22:11:48 +02:00
jp39 0a3a7881c6 Add native Kagi web search provider 2026-06-07 16:27:50 +02:00
程智超0668000959 639f700c15 fix(agent): add ok checks for startup info type assertions
GetStartupInfo returns map[string]any, and type-asserting tools/skills entries without checking ok is fragile. While the current implementation always stores the correct types, a future refactor could cause silent nil dereference. Add ok checks with explicit nil fallback.
2026-06-07 21:28:45 +08:00
程智超0668000959 cbb684be01 fix: handle os.Getwd error in evolution skills_recall and drafts
When os.Getwd fails, wd is empty and builtinSkillsDir resolves to relative path, causing confusing downstream errors. Fall back to config.GetHome on error.
2026-06-07 21:05:16 +08:00
Mauro 67eaa984c7 Merge pull request #3040 from chengzhichao-xydt/codex/model-status-type-assert
fix: add ok check for singleflight type assertion in model probe
2026-06-07 15:03:45 +02:00
Mauro ebb04abb38 Merge pull request #3034 from chengzhichao-xydt/codex/feishu-resource-close-error
fix: check Close() error on feishu resource download
2026-06-07 14:58:21 +02:00
程智超0668000959 a011df1ddc fix: add ok check for singleflight type assertion in model probe
singleflight.Group.Do() returns any, which is type-asserted as bool
without an ok check at model_status.go:211. If a non-bool value is
returned (e.g. nil from shared/cache corruption), this panics.

Add ok check and return false (model probe failed) as a safe default.
2026-06-07 20:54:00 +08:00
程智超0668000959 f037a112b2 fix: avoid err shadow in feishu close check
Use distinct variable names (writeErr, closeErr) to avoid
shadowing the outer err, so a deferred close failure is
still captured.
2026-06-07 20:35:57 +08:00
Mauro 10115f941c Merge pull request #3035 from chengzhichao-xydt/codex/file-copy-close-errors
fix: check Close() error after io.Copy to writable files
2026-06-07 14:06:57 +02:00
Mauro db13367404 Merge pull request #3036 from SutraHsing/codex/2941-claude-sonnet-model-id
fix(config): use canonical Anthropic default model ID
2026-06-07 14:02:25 +02:00
Mauro 7c18fe8421 Merge pull request #3033 from chengzhichao-xydt/codex/media-close-errors
fix: check Close() error after downloading media file
2026-06-07 14:00:34 +02:00
Sutra Hsing 007b2ae8bd fix(config): use canonical Anthropic default model ID 2026-06-07 15:43:26 +08:00
程智超0668000959 2d1fb953fc fix: check Close() error after io.Copy to writable files 2026-06-07 12:09:01 +08:00
程智超0668000959 b1d727ebaf fix: check Close() error on feishu resource download 2026-06-07 11:57:10 +08:00
程智超0668000959 f7be21bb11 fix: check Close() error after downloading media file 2026-06-07 11:53:38 +08:00
Mauro 7d2b0c2a4d Merge pull request #3021 from chengzhichao-xydt/codex/safe-startup-info
fix: safe startup info map access to prevent panic on nil agent
2026-06-06 16:21:46 +02:00
Mauro c19e4e8db1 Merge pull request #3022 from chengzhichao-xydt/codex/sync-map-assertions
fix: add ok checks for sync.Map LoadAndDelete/Load type assertions
2026-06-06 16:20:28 +02:00
Mauro ebf17aa152 Merge pull request #3023 from chengzhichao-xydt/codex/updater-close-errors
fix: check Close() errors in updater extraction functions
2026-06-06 16:12:41 +02:00
程智超0668000959 4290aa8b5b fix: check Close() errors in updater extraction functions 2026-06-06 21:34:24 +08:00
程智超0668000959 5f0d368995 fix: add ok checks for sync.Map LoadAndDelete/Load type assertions 2026-06-06 21:17:07 +08:00
程智超0668000959 ddabaa69a4 fix: safe startup info map access to prevent panic on nil agent 2026-06-06 21:13:22 +08:00
Mauro ff7c92deee Merge pull request #3019 from chengzhichao-xydt/codex/lastinsertid-nilguard
fix: type-switch capture, nil guard, check LastInsertId errors
2026-06-06 12:55:38 +02:00
程智超0668000959 4752a67a7c fix: type-switch capture, nil guard, LastInsertId error check
Three defensive fixes: 1) whatsapp_native - use type-switch capture instead of redundant unchecked assertion 2) config - add nil receiver guard to FilterSensitiveData 3) seahorse/store - check LastInsertId error in 3 locations
2026-06-06 16:32:14 +08:00
Mauro 89ee8f1b39 Merge pull request #2915 from SiYue-ZO/feat/mimo-common-models
feat(providers): add CommonModels for MiMo provider
2026-06-05 20:52:29 +02:00
Mauro b10f9cdf18 Merge pull request #2985 from chengzhichao-xydt/codex/context-show-summarize-threshold
fix(context): show both summarize and compress thresholds in /context
2026-06-05 19:24:54 +02:00
Mauro 0b7aaac2b2 Merge pull request #3009 from chengzhichao-xydt/codex/onebot-group-reply-fix
fix(onebot): use prefixed chatID for group reply routing
2026-06-05 19:14:56 +02:00
Mauro 8e7e910f67 Merge pull request #3010 from chengzhichao-xydt/codex/channel-hash-type-assertions
fix(channels): add ok checks for type assertions in toChannelHashes
2026-06-05 19:12:45 +02:00
Mauro 71524183b6 Merge pull request #3011 from chengzhichao-xydt/codex/legacy-events-ok-assert
fix(agent): add ok check for LoadAndDelete type assertion
2026-06-05 19:12:22 +02:00
程智超0668000959 6c882ec5e7 fix(agent): log warning when LoadAndDelete type assertion fails
Add a warning log when the type assertion from sync.Map.LoadAndDelete fails in UnsubscribeEvents, per review suggestion. This makes a mismatched type observable for debugging.
2026-06-06 00:32:49 +08:00
程智超0668000959 9f246a6482 test(channels): add edge case tests for toChannelHashes type assertions
Add 3 tests covering scenarios that previously panicked: 1) missing enabled key in settings 2) enabled field with non-bool type 3) teams_webhook with webhooks using map[string]any from JSON unmarshal
2026-06-06 00:31:44 +08:00
程智超0668000959 7a7e205cc8 fix(context): expose history tokens and remove leaked state files
Address remaining review feedback: 1) Add HistoryTokens field to ContextUsage/ContextStats, showing history-only token count in /context and frontend UI alongside SummarizeAtTokens so users can see the actual summarization trigger comparison. 2) Remove .codebuddy/github-contribute/ state files accidentally included in the PR.
2026-06-06 00:28:32 +08:00
Mauro 1f2736915e Merge pull request #3013 from shenjiecode/docs/fix-skill-creator-scaffold
docs: remove missing skill-creator helper script references
2026-06-05 13:19:51 +02:00
Jay Shen 12ca46b1ab docs: remove missing skill-creator helper script references 2026-06-05 17:07:06 +08:00
Mauro cc712a1adb Merge pull request #2979 from afjcjsbx/fix/pr-2962
fix: support anthropic-sdk-go v1.46.0 in anthropic provider
2026-06-05 10:04:44 +02:00
Mauro 52e3ea72ba Merge pull request #3001 from chengzhichao-xydt/codex/workspace-guard-schemeless-url
fix(tools): allow scheme-less URLs in workspace guard
2026-06-05 08:54:59 +02:00
程智超0668000959 f0f809db35 fix(agent): add ok check for LoadAndDelete type assertion
sync.Map.LoadAndDelete returns any; unprotected type assertion could panic if an unexpected type were stored. Add ok check to safely handle mismatched types.
2026-06-05 10:12:14 +08:00
程智超0668000959 e5c7772d3c fix(channels): add ok checks for type assertions in toChannelHashes
Two type assertions in toChannelHashes could panic when channel config values had unexpected types from JSON unmarshal: 1) value[enabled].(bool) panics if the key is missing or not a bool 2) vv.(map[string]string) panics when JSON unmarshal produces map[string]any. Add ok checks to safely handle both cases.
2026-06-05 09:49:44 +08:00
程智超0668000959 32ea611f0c fix(onebot): use prefixed chatID for group reply routing
When an incoming group message is received, the inbound context ChatID was set to the raw group number without the group: prefix. This caused the outbound reply to use send_private_msg instead of send_group_msg. Fix by using the prefixed chatID as inbound context ChatID. Closes #3002
2026-06-05 09:37:00 +08:00
程智超0668000959 b6030f054d chore: update contribution state files 2026-06-05 09:28:44 +08:00
程智超0668000959 296a8ae287 fix(context): address review - clarify threshold alignment, i18n strings, add test coverage 2026-06-05 09:27:45 +08:00
程智超0668000959 a6735517d2 test(tools): add unit tests for scheme-less URL workspace guard detection 2026-06-05 09:17:40 +08:00
Meng Zhuo 5224b9a4bc Merge pull request #3008 from afjcjsbx/fix/larksuite-v3.9.4-compat
fix: adapt to larksuite oapi-sdk-go v3.9.4 breaking changes (follow-up to #3005)
2026-06-05 08:34:33 +08:00
Mauro 976ecc68b7 Merge pull request #3000 from chengzhichao-xydt/codex/pid-verify-process-identity
fix(pid): verify process identity in singleton PID check
2026-06-05 00:02:55 +02:00
Mauro dbd76fe541 Merge pull request #2999 from chengzhichao-xydt/codex/makefile-go-version-space
fix: handle space in go env GOVERSION with firstword
2026-06-04 23:39:43 +02:00
Mauro 49e3a03def fix: adapt to larksuite oapi-sdk-go v3.9.4 breaking changes
The SDK renamed ReceiveIdTypeChatId to CreateMessageV1ReceiveIDTypeChatId
in v3.9.4. Update all 5 usages in feishu_64.go and bump the dependency
version.

This fixes the build failure for Dependabot PR #3005.
2026-06-04 23:19:04 +02:00
Mauro d5bd06dc0d Merge pull request #3007 from SebastianBoehler/codex/fix-codex-oauth-stream-tools
fix: preserve streamed Codex tool calls
2026-06-04 21:24:54 +02:00
Mauro d009ba32b7 Merge pull request #3004 from sipeed/dependabot/go_modules/github.com/aws/aws-sdk-go-v2/service/bedrockruntime-1.53.3
build(deps): bump github.com/aws/aws-sdk-go-v2/service/bedrockruntime from 1.50.6 to 1.53.3
2026-06-04 20:12:50 +02:00
Mauro 9b0ab22b3d Merge pull request #3003 from sipeed/dependabot/go_modules/modernc.org/sqlite-1.51.0
build(deps): bump modernc.org/sqlite from 1.50.1 to 1.51.0
2026-06-04 20:12:10 +02:00
SebastianBoehler 3e6abba803 fix: preserve streamed Codex tool calls 2026-06-04 19:27:29 +02:00
dependabot[bot] 79aefc5062 build(deps): bump github.com/aws/aws-sdk-go-v2/service/bedrockruntime
Bumps [github.com/aws/aws-sdk-go-v2/service/bedrockruntime](https://github.com/aws/aws-sdk-go-v2) from 1.50.6 to 1.53.3.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/ecr/v1.50.6...service/iot/v1.53.3)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/bedrockruntime
  dependency-version: 1.53.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-04 17:15:07 +00:00
dependabot[bot] 9da23e7804 build(deps): bump modernc.org/sqlite from 1.50.1 to 1.51.0
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.50.1 to 1.51.0.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.50.1...v1.51.0)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-04 17:13:45 +00:00
程智超0668000959 a90d8d35ee fix(pid): verify process identity in singleton PID check
isProcessRunning() previously only checked whether a PID existed via signal(0)/OpenProcess, without confirming the process was actually picoclaw. When the PID was reused by an unrelated process (e.g., systemd-resolved after a kill -9), the gateway would refuse to start with 'already running'.

Add isPicoclawProcess() that verifies the process name matches picoclaw:
- Unix: reads /proc/<pid>/comm
- Windows: calls QueryFullProcessImageNameW

If the running process is not picoclaw, treat the PID file as stale and proceed with normal startup. Falls back to trusting the liveness check when identity verification is unavailable (e.g., /proc unreadable, API call fails).

Fixes #2720.
2026-06-04 20:04:51 +08:00
程智超0668000959 b86ab71836 fix(tools): allow scheme-less URLs in workspace guard
The workspace guard's absolutePathPattern regex matches /Beijing?T in commands like 'curl wttr.in/Beijing'. Since 'wttr.in' is not a recognized web scheme, the path was routed through workspace sandbox validation, which could block legitimate scheme-less URL usage (curl allows bare domains without http://).

Add detection for domain-like tokens preceding /path matches:
- looksLikeDomain: checks for dot-separated tokens that don't end with common file extensions (.py, .go, .exe, etc.)
- localPathExists: verifies the token does not exist as a local filesystem entry

This dual guard prevents the symlink bypass identified in PR #2965 review: if 'foo.bar' exists as a local symlink or directory, the path still undergoes full workspace validation.

Fixes #1042.
2026-06-04 19:59:49 +08:00
Mauro 0ce6e20e08 Merge pull request #2996 from chengzhichao-xydt/codex/handle-json-marshal-errors
fix(tools): handle json.Marshal errors in exec tool responses
2026-06-04 11:50:42 +02:00
程智超0668000959 36ca85ad09 fix: handle space in go env GOVERSION with firstword
go env GOVERSION may return values like go1.25.10 X:nodwarf5 with an embedded space on some toolchain configurations, breaking -ldflags. Use firstword to extract only the first token. Fixes #2976.
2026-06-04 17:44:07 +08:00
程智超0668000959 734f53fb37 fix(tools): handle json.Marshal errors in exec tool responses
Replace 7 instances of ignored json.Marshal errors with proper error handling. Previously, if marshaling an ExecResponse failed, a nil byte slice would be silently converted to an empty string in the LLM response. Now each site returns ErrorResult with the marshal error message.
2026-06-04 17:31:26 +08:00
Mauro 6e9b5071b0 Merge pull request #2995 from chengzhichao-xydt/codex/update-docs-v0.2.9
docs: add v0.2.5~v0.2.9 release highlights to README News
2026-06-04 09:02:01 +02:00
Mauro aa49d066b0 Merge pull request #2992 from chengzhichao-xydt/codex/skip-main-session-alias-promotion
fix(session): skip main-session alias during history promotion
2026-06-04 09:01:26 +02:00
程智超0668000959 5f826f4448 fix(context): show both summarize and compress thresholds in /context
The /context command previously showed only the hard budget compression
threshold (contextWindow - maxTokens), which confused users who expected
to see the soft summarization trigger from summarize_token_percent.

This commit adds SummarizeAtTokens alongside the existing CompressAtTokens
so that both thresholds are visible:

- Compress at: contextWindow - maxTokens (hard budget, triggers proactive
  compression when exceeded)
- Summarize at: contextWindow * summarizeTokenPercent / 100 (soft trigger,
  matches maybeSummarize's threshold)

The fix updates the /context command output, the Web UI popover, and the
pico channel WebSocket payload.

Fixes #2968
2026-06-04 11:03:16 +08:00
程智超0668000959 04664ab514 fix(session): tighten main-session alias detection to exact 3-part format
Only match agent:X:main, not agent:X:direct:main or agent:X:slack:channel:main. Review feedback from afjcjsbx.
2026-06-04 11:01:10 +08:00
程智超0668000959 9c71a44421 fix(session): skip main-session alias during history promotion
The PromoteAliasHistory method previously promoted the first non-empty alias session into a new canonical session. When a user upgraded, the migrated main session contained old messages that were copied into every new Web UI session because agent:main:main is always the first alias.

Add isMainSessionAlias() to detect and skip the main session alias during promotion. Fixes #2972.
2026-06-04 11:01:10 +08:00
程智超0668000959 e1d9a62e0e docs: add v0.2.5~v0.2.9 release highlights to README News
The News section stopped at v0.2.4. Add release highlights for v0.2.5 through v0.2.9 based on GitHub release changelogs.
2026-06-04 10:59:17 +08:00
Meng Zhuo 709c8b2b52 Merge pull request #2997 from afjcjsbx/fix/update-go-1.25.11
fix(deps): bump go from 1.25.10 to 1.25.11 (GO-2026-5039)
2026-06-03 18:43:31 +08:00
Mauro 5d4840c979 fix(deps): bump go from 1.25.10 to 1.25.11 (GO-2026-5039)
net/textproto: header names not escaped in error messages

Affects go < 1.25.11. Fixed in go 1.25.11.
2026-06-03 12:37:54 +02:00
Meng Zhuo a502aa7f83 Merge pull request #2994 from afjcjsbx/feat/picoclaw-agent-skill-expansion
docs(skill): self describing Picoclaw agent skill
2026-06-03 08:59:33 +08:00
afjcjsbx e74ac70cf9 docs(skill): logs detection 2026-06-02 19:35:13 +02:00
afjcjsbx 8dffd6ff03 docs(skill): complete picoclaw-agent skill documentation 2026-06-02 19:29:27 +02:00
afjcjsbx 1903a18235 Merge remote-tracking branch 'upstream/main' 2026-06-02 18:59:12 +02:00
Mauro 004f9346c1 Merge pull request #2991 from chengzhichao-xydt/codex/retry-transient-llm-errors
fix(agent): retry transient LLM HTTP errors using provider error classifier
2026-06-02 18:45:35 +02:00
Mauro 827cd32ffc Merge pull request #2986 from chengzhichao-xydt/codex/session-manager-stop-cleanup
fix(tools): add Stop() to SessionManager to prevent goroutine leak
2026-06-02 18:37:55 +02:00
afjcjsbx 379ab9af2f Merge remote-tracking branch 'upstream/main' 2026-06-02 18:36:38 +02:00
程智超0668000959 e70a9fca7c fix(tools): use sync.Once for thread-safe Stop() in SessionManager
The Stop() method previously used a select/default pattern which was not
safe under concurrent calls — two goroutines could both pass the check
and attempt to close the same channel, causing a panic.

Replace with sync.Once to guarantee exactly-once close semantics,
matching the documented contract of being safe for concurrent use.

Review feedback: afjcjsbx
2026-06-02 20:20:30 +08:00
程智超0668000959 99a7179e76 fix(agent): retry transient LLM HTTP errors using provider error classifier
Previously, only timeout and network errors (matched via string
patterns) were retried. HTTP 500 server errors from
OpenRouter/OpenAI-compatible providers would fail the agent turn
immediately when no model fallback candidate was available.

This commit replaces the separate timeout/network retry branches
with a unified transientLLMRetryReason() helper that:
1. Uses providers.ClassifyError() to detect server_error (HTTP >=500),
   timeout, network, and rate_limit errors
2. Falls back to the existing string-based detection for errors
   not classified by the provider

A regression test (TestPipeline_CallLLM_HTTP5xxRetry) verifies that
HTTP 500 errors are retried and recover successfully.

This is a clean rebase of the approach originally proposed in #2768
by afjcjsbx.
2026-06-02 19:58:09 +08:00
Mauro 7b47872334 Merge pull request #2989 from yuxuan-7814/fix/2943-zhipu-error-1210
fix(providers): add Zhipu API error code 1210 to format error patterns
2026-06-02 12:20:16 +02:00
yuxuan-7814 5927ecc394 fix(providers): add Zhipu API error code 1210 to format error patterns
This fixes issue #2943 where WeChat channel image requests to Zhipu
GLM-5-Turbo vision API failed with error code 1210 (parameter error)
without triggering the fallback mechanism.

Changes:
- Added error code 1210 pattern matching to formatPatterns
- This allows the fallback mechanism to recognize Zhipu API parameter
  errors and fall back to alternative vision models

Closes #2943
2026-06-02 17:26:14 +08:00
程智超0668000959 bb57e0498c fix(tools): add Stop() to SessionManager to prevent goroutine leak
The SessionManager's background cleanup goroutine previously had no
shutdown mechanism. Each call to NewSessionManager() started a ticker
goroutine that ran indefinitely. In tests, where multiple
SessionManagers are created, this caused goroutine leaks.

This commit adds a Stop() method that cleanly shuts down the background
cleanup goroutine via a channel. Stop() is safe to call multiple times.
All existing tests now call t.Cleanup(sm.Stop) to ensure cleanup.
2026-06-02 17:13:31 +08:00
afjcjsbx e42006c10d Merge remote-tracking branch 'upstream/main' 2026-06-01 18:49:57 +02:00
Mauro 426046fca0 Merge pull request #2977 from SutraHsing/cron-get-update
feat(cron): add get and update actions to cron tool
2026-06-01 14:16:50 +02:00
sutra 28eafaeef2 refactor(cron): flatten if-else chains and suppress dupl lint 2026-06-01 20:08:40 +08:00
Mauro 1cfa781925 Merge pull request #2982 from loafoe/fix/bedrock-opus48-temperature
fix(bedrock): drop temperature for models that deprecate it (Opus 4.8)
2026-06-01 13:02:18 +02:00
Andy Lo-A-Foe 5a997a86f0 fix(bedrock): drop temperature for models that deprecate it
Claude Opus 4.8 on Bedrock rejects the temperature inference parameter
with a ValidationException ("temperature is deprecated for this model").
buildConverseParams now takes the model id and omits temperature for
claude-opus-4-8* (matching both bare model ids and region-prefixed
inference profiles), logging when it does so. max_tokens and all other
models are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 08:59:15 +02:00
afjcjsbx 672f86c670 Merge remote-tracking branch 'origin/main' 2026-05-31 23:11:43 +02:00
afjcjsbx 4e3e90df26 Merge remote-tracking branch 'upstream/main' 2026-05-31 23:11:18 +02:00
sutra be13201f02 feat(cron): restrict list/get/update to accessible jobs per channel 2026-05-31 19:20:41 +08:00
afjcjsbx 6e0e2906aa fix(anthropic): support anthropic-sdk-go v1.46.0 2026-05-31 11:54:35 +02:00
afjcjsbx c0f2714b66 Merge remote-tracking branch 'upstream/main' 2026-05-31 11:39:04 +02:00
Mauro ba8065923b Merge pull request #2856 from bogdanovich/feat/message-media-outbound
feat(message): support media attachments and Telegram rich delivery
2026-05-31 11:38:38 +02:00
Mauro 13e1833c81 Merge pull request #2967 from miruchigawa/main
fix(codex): preserve streamed output text deltas
2026-05-31 11:24:20 +02:00
sutra 1d8ef7dcfb feat(cron): add get and update actions to cron tool
Add GetJob and improved UpdateJob to CronService with proper cloning,
schedule diffing, and next-run recomputation. Expose get/update actions
in the cron tool so agents can inspect and partially update jobs without
losing payloads or needing remove+add cycles. Includes access control
for remote channels and command safety gates.
2026-05-31 10:55:54 +08:00
miruchigawa 93391223ea fix: format long line in codex_provider_test.go to satisfy golines 2026-05-31 05:00:22 +07:00
afjcjsbx 41a108c9af Merge remote-tracking branch 'upstream/main' 2026-05-30 20:29:53 +02:00
Mauro 1ce353ba28 Merge pull request #2969 from lc6464/feat/webchat-image-paste-dnd
feat(web): add chat image paste and drag-and-drop upload
2026-05-30 20:22:56 +02:00
dependabot[bot] 4b8761ce6d build(deps): bump github.com/anthropics/anthropic-sdk-go
Bumps [github.com/anthropics/anthropic-sdk-go](https://github.com/anthropics/anthropic-sdk-go) from 1.26.0 to 1.46.0.
- [Release notes](https://github.com/anthropics/anthropic-sdk-go/releases)
- [Changelog](https://github.com/anthropics/anthropic-sdk-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/anthropics/anthropic-sdk-go/compare/v1.26.0...v1.46.0)

---
updated-dependencies:
- dependency-name: github.com/anthropics/anthropic-sdk-go
  dependency-version: 1.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-30 17:51:35 +00:00
Mauro 63ba146015 Merge pull request #2974 from kunalk16/feat-i18n-bangla
feat(i18n): Add Bangla support bn-in
2026-05-30 19:47:17 +02:00
Mauro 16c26338b6 Merge pull request #2971 from kunalk16/feat-azureopenai-identity
feat(provider): Add optional Azure Identity support for Azure OpenAI provider
2026-05-30 19:46:32 +02:00
Kunal Karmakar 2391f32fc1 Add Bangla support bn-in 2026-05-30 14:52:54 +00:00
Kunal Karmakar 46e5b59d5f Fix linting 2026-05-30 14:11:39 +00:00
Kunal Karmakar 995005a0ba Add azure entra id support for azure openai provider 2026-05-30 13:40:29 +00:00
lc6464 1edb873ace feat(web): add chat image paste and drag-and-drop upload 2026-05-30 18:21:40 +08:00
miruchigawa 2ff8b01cc6 fix(codex): preserve streamed output text deltas
OpenAI/Codex OAuth streams can return text through response.output_text.delta while the final response.completed payload has response.output set to null. That made PicoClaw report an empty model response even though the backend returned valid content.

Accumulate streamed output_text delta events during the Codex response stream and use them as a fallback when the parsed final response has no content. Add a regression test covering the null final output case from issue #2953.
2026-05-30 10:12:29 +07:00
afjcjsbx e1bada5b94 Merge remote-tracking branch 'upstream/main' 2026-05-29 10:16:05 +02:00
Mauro e81d37108b Merge pull request #2932 from KrtCZ/feat/czech-i18n
feat(i18n): add Czech (cs) locale
2026-05-29 10:14:42 +02:00
Mauro 4e280c5f5e Merge pull request #2961 from sipeed/dependabot/go_modules/github.com/pion/rtp-1.10.2
build(deps): bump github.com/pion/rtp from 1.10.1 to 1.10.2
2026-05-29 10:11:30 +02:00
Mauro 6247f47628 Merge pull request #2960 from sipeed/dependabot/go_modules/github.com/caarlos0/env/v11-11.4.1
build(deps): bump github.com/caarlos0/env/v11 from 11.4.0 to 11.4.1
2026-05-29 10:11:08 +02:00
afjcjsbx 32282beef8 Merge remote-tracking branch 'upstream/main' 2026-05-29 10:03:53 +02:00
Guoguo f9f53e30ee docs: update wechat qrcode (#2966) 2026-05-29 10:33:02 +08:00
dependabot[bot] a34669a2d8 build(deps): bump github.com/pion/rtp from 1.10.1 to 1.10.2
Bumps [github.com/pion/rtp](https://github.com/pion/rtp) from 1.10.1 to 1.10.2.
- [Release notes](https://github.com/pion/rtp/releases)
- [Commits](https://github.com/pion/rtp/compare/v1.10.1...v1.10.2)

---
updated-dependencies:
- dependency-name: github.com/pion/rtp
  dependency-version: 1.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 19:27:12 +00:00
dependabot[bot] f797172a86 build(deps): bump github.com/caarlos0/env/v11 from 11.4.0 to 11.4.1
Bumps [github.com/caarlos0/env/v11](https://github.com/caarlos0/env) from 11.4.0 to 11.4.1.
- [Release notes](https://github.com/caarlos0/env/releases)
- [Commits](https://github.com/caarlos0/env/compare/v11.4.0...v11.4.1)

---
updated-dependencies:
- dependency-name: github.com/caarlos0/env/v11
  dependency-version: 11.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 19:27:05 +00:00
afjcjsbx 8e0964be24 Merge remote-tracking branch 'upstream/main' 2026-05-28 19:57:53 +02:00
Mauro 85751492c6 Merge pull request #2950 from yuxuan-7814/fix/2912-add-funding-yml
docs: add FUNDING.yml for GitHub Sponsors
2026-05-28 19:50:38 +02:00
Mauro 0b7e18cd9e Merge pull request #2949 from yuxuan-7814/fix/2944-termux-ssl-cert
fix: auto-detect Termux SSL certificate path
2026-05-28 19:43:18 +02:00
Andy Lo-A-Foe 89e7a61a69 fix(channels): prevent tool_calls from being dropped during streaming
The auxiliary message filtering introduced in #2892 incorrectly drops
tool_calls messages when there is an active stream or a tombstone from
a recently finished stream.

This causes tool_calls to not be delivered to the UI when users make
consecutive requests, as the second request's tool_calls message arrives
while the first request's stream is still active or within the 30-second
tombstone window.

The fix excludes tool_calls from auxiliary message filtering, since they
represent new tool invocations that must be delivered to the UI, not
stale auxiliary content like feedback or thoughts.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-27 20:36:23 +02:00
yuxuan-7814 e9e653fb13 docs: add FUNDING.yml for GitHub Sponsors
Add FUNDING.yml file to enable GitHub Sponsors button on the repo.
This makes it easy for users who benefit from PicoClaw to support
the project financially.

Closes #2912
2026-05-26 16:53:33 +08:00
yuxuan-7814 5755b5b323 fix: auto-detect Termux SSL certificate path
When running PicoClaw inside Termux or termux-chroot, HTTPS
requests fail with X509 certificate errors because the Go TLS
stack does not automatically detect the Termux CA bundle path.

This change adds automatic detection of Termux environments and
sets SSL_CERT_FILE to the correct CA bundle path before any
network operations. The detection checks:
- HOME or PATH contains 'com.termux'
- Common CA bundle locations in Termux prefix

Fixes #2944
2026-05-26 16:49:42 +08:00
afjcjsbx 65c09d4270 Merge remote-tracking branch 'upstream/main' 2026-05-26 09:22:23 +02:00
LC 28ec5793a8 feat(web): add line numbers and wrap toggle for code blocks (#2933)
* feat(web): add line numbers and wrap toggle for code blocks

* fix(web): preserve markdown code block copy semantics
2026-05-26 14:57:52 +08:00
Mauro c5a016ccc6 Merge pull request #2946 from lc6464/feat/seahorse-created-at-history
fix(seahorse,session): preserve created_at across history bootstrap
2026-05-26 08:40:52 +02:00
lc6464 9825b4782f fix(seahorse,session): preserve created_at across history bootstrap 2026-05-26 14:05:20 +08:00
afjcjsbx f5f6fdc1f9 Merge remote-tracking branch 'origin/main' 2026-05-25 15:33:21 +02:00
afjcjsbx cfbddcd117 Merge remote-tracking branch 'upstream/main' 2026-05-25 15:32:55 +02:00
afjcjsbx 7be20bf70a Merge remote-tracking branch 'upstream/main' 2026-05-25 13:52:26 +02:00
Mauro ab6d3946a5 Merge pull request #2938 from hschne/fix/cron-command-action
fix(cron): add missing action arg for command job execution
2026-05-24 22:07:18 +02:00
hschne 7af40d49eb fix(cron): add missing 'action' arg for command job execution
CronTool.ExecuteJob was calling ExecTool.Execute without setting
action='run' in the args map. ExecTool.Execute requires the action
field and returns ErrorResult('action is required') immediately when
it's missing. This caused all cron command jobs to silently fail.

Adds a test covering the command execution happy path.
2026-05-24 20:25:06 +02:00
afjcjsbx 239a98e18b Merge remote-tracking branch 'upstream/main' 2026-05-23 17:40:04 +02:00
Mauro d499cbece4 Merge pull request #2931 from hschne/fix/discord-image-download
fix(discord): download attachments for vision pipeline
2026-05-23 17:39:37 +02:00
afjcjsbx d48fa2e2fd Merge remote-tracking branch 'upstream/main' 2026-05-23 17:29:50 +02:00
Mauro e95bcaf3e3 Merge pull request #1 from afjcjsbx/codex/resolve-main-upstream-merge
Merge upstream/main into main
2026-05-23 17:24:20 +02:00
afjcjsbx fbea699936 chore: move resolved upstream merge off main 2026-05-23 17:15:00 +02:00
Martin Zapletal 23e1485a98 Add Čeština to language switcher 2026-05-23 13:42:03 +02:00
Martin Zapletal edcae17b41 Register Czech (cs) locale in i18n config 2026-05-23 13:35:44 +02:00
Martin Zapletal d609e83313 Add Czech (cs) locale (792 strings) 2026-05-23 13:16:25 +02:00
hschne 96b4c543f4 fix(discord): download attachments for vision pipeline
Discord only downloaded audio attachments before passing them to the agent. Non-audio attachments (images, videos, files) were passed as raw Discord CDN URLs, which do not flow through resolveMediaRefs and are not serialized as vision inputs.

Download every attachment, store it in the MediaStore with Discord's filename and content type metadata, and emit a media placeholder tag that matches the attachment kind. This lets resolveMediaRefs replace the placeholder with the local path-bearing tag and encode supported images for vision-capable providers. If a download fails, keep the previous raw URL fallback.
2026-05-23 10:01:33 +02:00
Mauro 477028f8f2 Merge pull request #2895 from afjcjsbx/fix/seahorse-fresh-tail-budget
fix(seahorse): enforce budget on fresh tail and rebuild paths
2026-05-23 09:47:26 +02:00
afjcjsbx 9bb44b0a80 fix lint 2026-05-23 09:42:56 +02:00
afjcjsbx 6a97b1b087 Merge remote-tracking branch 'upstream/main' into fix/seahorse-fresh-tail-budget
# Conflicts:
#	pkg/agent/pipeline_llm.go
#	pkg/agent/pipeline_setup.go
#	pkg/agent/turn_state.go
2026-05-23 09:33:33 +02:00
Mauro 020bef2759 Merge pull request #2928 from lc6464/feat/deepseek-thinking-fields
feat(openai_compat): map DeepSeek thinking fields
2026-05-23 09:24:50 +02:00
afjcjsbx 848bf77381 Merge branch 'fix/seahorse-fresh-tail-budget'
# Conflicts:
#	pkg/agent/pipeline_llm.go
#	pkg/agent/pipeline_setup.go
#	pkg/agent/turn_state.go
2026-05-23 09:23:30 +02:00
lc6464 3a454593ca feat(openai_compat): map DeepSeek thinking fields 2026-05-23 10:51:24 +08:00
Anton Bogdanovich ceebda35ee fix(message): gate local media attachments 2026-05-22 16:36:44 -07:00
Anton Bogdanovich 1bf0d898de test(message): cover slack and feishu media fallbacks 2026-05-22 16:28:28 -07:00
Anton Bogdanovich c05e5e29c6 test(message): cover pico and weixin media text semantics 2026-05-22 16:25:50 -07:00
Anton Bogdanovich 987f117f31 style(telegram): satisfy formatter rules 2026-05-22 16:25:50 -07:00
Anton Bogdanovich 5a4e42d1b6 feat(message): support media attachments in outbound tool 2026-05-22 16:25:50 -07:00
Mauro f09a7d67f7 Merge pull request #2930 from lc6464/fix/security-xnet-html-0.55.0
build(deps): bump golang.org/x/net to v0.55.0
2026-05-22 19:46:28 +02:00
Mauro 2cce7b8abe Merge pull request #2788 from LiusCraft/feat/session-message-timestamps
feat(session): add per-message created_at timestamps
2026-05-22 19:45:53 +02:00
lc6464 044a9d1df6 fix(deps): bump golang.org/x/net to v0.55.0 2026-05-23 00:33:03 +08:00
Mauro d3ac0a74c4 Merge pull request #2921 from sipeed/dependabot/go_modules/github.com/adhocore/gronx-1.20.0
build(deps): bump github.com/adhocore/gronx from 1.19.7 to 1.20.0
2026-05-22 08:49:50 +02:00
Mauro 24e8285e73 Merge pull request #2923 from sipeed/dependabot/go_modules/github.com/line/line-bot-sdk-go/v8-8.20.0
build(deps): bump github.com/line/line-bot-sdk-go/v8 from 8.19.0 to 8.20.0
2026-05-22 08:49:24 +02:00
LiusCraft 33e5503e26 fix(session): normalize CreatedAt in SessionManager AddFullMessage/SetHistory 2026-05-22 13:42:35 +08:00
LiusCraft fd08ebd3db fix(test): read back history after SetHistory in steering test for CreatedAt normalization 2026-05-22 13:15:56 +08:00
LiusCraft 34e73f6b1a fix(test): read back history after SetHistory to account for CreatedAt normalization 2026-05-22 13:15:56 +08:00
LiusCraft 3e30e8abc6 style: wrap long error messages to satisfy golines 2026-05-22 13:15:56 +08:00
LiusCraft 81bbef62b1 feat(session): add per-message created_at timestamps
- Persistence layer (jsonl.go addMsg/SetHistory) normalizes CreatedAt
  when missing so the invariant is guaranteed at the storage boundary
- API layer (session.go) exposes created_at on all transcript message
  types with session.updated fallback for legacy messages
- Frontend uses per-message timestamps when available
- messagesContentEqual ignores CreatedAt for tail-matching after
  JSONL roundtrip

Fixes #2787
2026-05-22 13:15:56 +08:00
dependabot[bot] 76175b4bcf build(deps): bump github.com/line/line-bot-sdk-go/v8
Bumps [github.com/line/line-bot-sdk-go/v8](https://github.com/line/line-bot-sdk-go) from 8.19.0 to 8.20.0.
- [Release notes](https://github.com/line/line-bot-sdk-go/releases)
- [Commits](https://github.com/line/line-bot-sdk-go/compare/v8.19.0...v8.20.0)

---
updated-dependencies:
- dependency-name: github.com/line/line-bot-sdk-go/v8
  dependency-version: 8.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-21 18:28:37 +00:00
dependabot[bot] 0dfdb54198 build(deps): bump github.com/adhocore/gronx from 1.19.7 to 1.20.0
Bumps [github.com/adhocore/gronx](https://github.com/adhocore/gronx) from 1.19.7 to 1.20.0.
- [Release notes](https://github.com/adhocore/gronx/releases)
- [Changelog](https://github.com/adhocore/gronx/blob/main/CHANGELOG.md)
- [Commits](https://github.com/adhocore/gronx/compare/v1.19.7...v1.20.0)

---
updated-dependencies:
- dependency-name: github.com/adhocore/gronx
  dependency-version: 1.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-21 18:28:28 +00:00
SiYue-ZO 1bc7abfb50 feat(providers): add CommonModels for MiMo provider
Add mimo-v2.5 (multimodal) and mimo-v2.5-pro to MiMo's CommonModels so
the WebUI recommends vision-capable models by default. mimo-v2.5 supports
image understanding while mimo-v2.5-pro is text-only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 16:36:41 +08:00
SiYue-ZO 3a68d26837 Fix agent loop reload and panic cleanup stability 2026-05-21 10:05:58 +08:00
Puneet Dixit 17cf91771c docs: add Android Termux guide
Closes #286

Assisted-by: OpenAI Codex

Signed-off-by: Puneet Dixit <236133619+puneetdixit200@users.noreply.github.com>
2026-05-20 19:57:07 +05:30
afjcjsbx f0dcba8c5a fix(seahorse): preserve active tool-call turn when trimming fresh tail 2026-05-20 09:16:09 +02:00
afjcjsbx fe7ded5c13 fix(agent): preserve active turn during context retry rebuild 2026-05-19 09:18:39 +02:00
afjcjsbx 1502636bf0 fix(seahorse): enforce budget on fresh tail and rebuild paths 2026-05-18 21:11:21 +02:00
262 changed files with 21768 additions and 2510 deletions
+1
View File
@@ -1,3 +1,4 @@
# Ensure shell scripts always use LF line endings regardless of OS.
*.sh text eol=lf
docker/entrypoint.sh text eol=lf
.gitignore text eol=lf
+3
View File
@@ -0,0 +1,3 @@
# These are supported funding model platforms
github: [sipeed]
+2 -2
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Run Docker-backed integration suites
run: bash ./scripts/run-integration-tests.sh
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v6
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: main
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
ref: main
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
steps:
# ── Checkout ──────────────────────────────
- name: 📥 Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
ref: ${{ inputs.tag }}
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
+4 -4
View File
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v6
@@ -32,7 +32,7 @@ jobs:
GOFLAGS: -tags=goolm,stdjson
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
persist-credentials: false
@@ -52,7 +52,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v6
@@ -70,7 +70,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Run Docker-backed integration suites
run: bash ./scripts/run-integration-tests.sh
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
fi
- name: Checkout tag
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ inputs.tag }}
+1
View File
@@ -73,3 +73,4 @@ web/backend/dist/*
docker/data
.omc/
.worktrees/
+1 -1
View File
@@ -27,7 +27,7 @@ endif
VERSION?=$(if $(VERSION_RAW),$(VERSION_RAW),dev)
GIT_COMMIT=$(if $(GIT_COMMIT_RAW),$(GIT_COMMIT_RAW),dev)
BUILD_TIME=$(if $(BUILD_TIME_RAW),$(BUILD_TIME_RAW),dev)
GO_VERSION=$(if $(GO_VERSION_RAW),$(GO_VERSION_RAW),unknown)
GO_VERSION=$(if $(GO_VERSION_RAW),$(firstword $(GO_VERSION_RAW)),unknown)
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w
+18 -1
View File
@@ -64,6 +64,16 @@
</a>
</p>
2026-05-28 🚀 **v0.2.9 Released!** MCP server management in Web UI, configurable Sogou-backed web search, tool feedback animation in channels, `pretty_print` and `disable_escape_html` defaults, and numerous bug fixes across providers and channels.
2026-05-14 🚀 **v0.2.8 Released!** MCP CLI commands (`show`, `add`, `list`, `remove`, `test`, `edit`), empty object instead of null for MCP tool parameters, and build fixes.
2026-05-07 🚀 **v0.2.7 Released!** Configurable Sogou-backed web search, channel tool feedback animation, linter fixes.
2026-04-23 🚀 **v0.2.6 Released!** Hooks with respond action and comprehensive documentation, isolation support, help banner fix.
2026-04-11 🚀 **v0.2.5 Released!** Zoneinfo from TZ/ZONEINFO env, Matrix CommonMark rendering alignment, `read_file` by lines.
2026-03-31 📱 **Android Support!** PicoClaw now runs on Android! Download the APK at [picoclaw.io](https://picoclaw.io/download)
2026-03-25 🚀 **v0.2.4 Released!** Agent architecture overhaul (SubTurn, Hooks, Steering, EventBus), WeChat/WeCom integration, security hardening (.security.yml, sensitive data filtering), new providers (AWS Bedrock, Azure, Xiaomi MiMo), and 35 bug fixes. PicoClaw has reached **26K Stars**!
@@ -321,6 +331,8 @@ Download the APK from [picoclaw.io](https://picoclaw.io/download/) and install d
**Option 2: Termux**
For a full command-line setup checklist, see the [Android Termux Guide](docs/guides/android-termux.md).
<details>
<summary><b>Terminal Launcher (for resource-constrained environments)</b></summary>
@@ -408,17 +420,20 @@ PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Required | Mistral Large, Codestral |
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Required | NVIDIA hosted models |
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Required | Fast inference |
| [NEAR AI Cloud](https://near.ai/) | `nearai/` | Required | TEE inference, OpenAI-compatible |
| [Novita AI](https://novita.ai/) | `novita/` | Required | Various open models |
| [Xiaomi MiMo](https://platform.xiaomimimo.com/) | `mimo/` | Required | MiMo models |
| [Ollama](https://ollama.com/) | `ollama/` | Not needed | Local models, self-hosted |
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Not needed | Local deployment, OpenAI-compatible |
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Varies | Proxy for 100+ providers |
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Required | Enterprise Azure deployment |
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | API key or Entra ID** | Enterprise Azure deployment |
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Device code login |
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
| [AWS Bedrock](https://console.aws.amazon.com/bedrock)* | `bedrock/` | AWS credentials | Claude, Llama, Mistral on AWS |
> \* AWS Bedrock requires build tag: `go build -tags bedrock`. Set `api_base` to a region name (e.g., `us-east-1`) for automatic endpoint resolution across all AWS partitions (aws, aws-cn, aws-us-gov). When using a full endpoint URL instead, you must also configure `AWS_REGION` via environment variable or AWS config/profile.
>
> \*\* Azure OpenAI uses `api_key` when set. If `api_key` is omitted, the provider falls back to Microsoft Entra ID via `DefaultAzureCredential` (env vars, workload identity, managed identity, Azure CLI, etc.). The Entra ID path requires build tag: `go build -tags azidentity`.
<details>
<summary><b>Local deployment (Ollama, vLLM, etc.)</b></summary>
@@ -466,6 +481,7 @@ Talk to your PicoClaw through 19+ messaging platforms:
| **QQ** | Easy (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.md) |
| **Slack** | Easy (bot + app token) | Socket Mode | [Guide](docs/channels/slack/README.md) |
| **Matrix** | Medium (homeserver + token) | Sync API | [Guide](docs/channels/matrix/README.md) |
| **Delta Chat** | Easy (account script or email/password) | JSON-RPC (email/E2EE) | [Guide](docs/channels/deltachat/README.md) |
| **DingTalk** | Medium (client credentials) | Stream | [Guide](docs/channels/dingtalk/README.md) |
| **Feishu / Lark** | Medium (App ID + Secret) | WebSocket/SDK | [Guide](docs/channels/feishu/README.md) |
| **LINE** | Medium (credentials + webhook) | Webhook | [Guide](docs/channels/line/README.md) |
@@ -497,6 +513,7 @@ PicoClaw can search the web to provide up-to-date information. Configure in `too
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Required | 1500/month (daily allocation) | AI-powered, China-optimized |
| [Tavily](https://tavily.com) | Required | 1000 queries/month | Optimized for AI Agents |
| [Brave Search](https://brave.com/search/api) | Required | 2000 queries/month | Fast and private |
| [Kagi Search](https://help.kagi.com/kagi/api/search.html) | Required | Paid/limited by API setup | Premium search results |
| [Perplexity](https://www.perplexity.ai) | Required | Paid | AI-powered search |
| [SearXNG](https://github.com/searxng/searxng) | Not needed | Self-hosted | Free metasearch engine |
| [GLM Search](https://open.bigmodel.cn/) | Required | Varies | Zhipu web search |
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 KiB

After

Width:  |  Height:  |  Size: 261 KiB

+1 -1
View File
@@ -155,7 +155,7 @@ func (c *LLMClient) Complete(ctx context.Context, systemPrompt, userPrompt strin
}
respBody, lastErr = io.ReadAll(resp.Body)
resp.Body.Close()
_ = resp.Body.Close()
if lastErr != nil {
continue
}
+17 -6
View File
@@ -56,12 +56,23 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
// Print agent startup info (only for interactive mode)
startupInfo := agentLoop.GetStartupInfo()
logger.InfoCF("agent", "Agent initialized",
map[string]any{
"tools_count": startupInfo["tools"].(map[string]any)["count"],
"skills_total": startupInfo["skills"].(map[string]any)["total"],
"skills_available": startupInfo["skills"].(map[string]any)["available"],
})
toolsInfo, ok := startupInfo["tools"].(map[string]any)
if !ok {
toolsInfo = nil
}
skillsInfo, ok := startupInfo["skills"].(map[string]any)
if !ok {
skillsInfo = nil
}
logFields := map[string]any{}
if toolsInfo != nil {
logFields["tools_count"] = toolsInfo["count"]
}
if skillsInfo != nil {
logFields["skills_total"] = skillsInfo["total"]
logFields["skills_available"] = skillsInfo["available"]
}
logger.InfoCF("agent", "Agent initialized", logFields)
if message != "" {
ctx := context.Background()
+16
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
@@ -68,6 +69,21 @@ func TestFetchOpenAIModels_HTTPError(t *testing.T) {
assert.Contains(t, err.Error(), "HTTP 401")
}
func TestFetchOpenAIModels_HTTPErrorReadFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
body := "short"
w.Header().Set("Content-Length", strconv.Itoa(len(body)+1))
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
_, err := fetchOpenAIModels(srv.URL, "bad")
require.Error(t, err)
assert.Contains(t, err.Error(), "read error response")
assert.Contains(t, err.Error(), "unexpected EOF")
}
func TestFetchOpenAIModels_EmptyDataEnvelope(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"data":[]}`))
+4 -1
View File
@@ -45,7 +45,10 @@ func fetchOpenAIModels(baseURL, apiKey string) ([]modelEntry, error) {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 512))
if readErr != nil {
return nil, fmt.Errorf("read error response: %w", readErr)
}
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
+3 -5
View File
@@ -1,14 +1,12 @@
package onboard
import (
"embed"
"github.com/spf13/cobra"
picoclaw "github.com/sipeed/picoclaw"
)
//go:generate go run ../../../../scripts/copydir.go ../../../../workspace ./workspace
//go:embed workspace
var embeddedFiles embed.FS
var embeddedFiles = picoclaw.OnboardWorkspace
func NewOnboardCommand() *cobra.Command {
var encrypt bool
+11 -4
View File
@@ -79,7 +79,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, target string)
defer cancel()
if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
return fmt.Errorf("\u2717 failed to create skills directory: %v", err)
return fmt.Errorf("\u2717 failed to create skills directory: %w", err)
}
result, err := registry.DownloadAndInstall(ctx, target, "", targetDir)
@@ -302,6 +302,11 @@ func skillsSearchCmd(query string) {
if result.Version != "" {
fmt.Printf(" Version: %s\n", result.Version)
}
if result.RegistryName == "github" {
fmt.Printf(" Install: picoclaw skills install %s\n", result.Slug)
} else {
fmt.Printf(" Install: picoclaw skills install --registry=%s %s\n", result.RegistryName, result.Slug)
}
fmt.Println()
}
}
@@ -345,9 +350,11 @@ func copyDirectory(src, dst string) error {
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
_, copyErr := io.Copy(dstFile, srcFile)
if closeErr := dstFile.Close(); closeErr != nil && copyErr == nil {
return fmt.Errorf("close destination file %s: %w", dstPath, closeErr)
}
return copyErr
})
}
+48
View File
@@ -9,6 +9,8 @@ package main
import (
"fmt"
"os"
"runtime"
"strings"
"time"
"github.com/spf13/cobra"
@@ -33,6 +35,49 @@ import (
var rootNoColor bool
// initTermuxSSL detects Termux environment and sets SSL_CERT_FILE if not already set.
// This fixes X509 certificate errors when running PicoClaw inside Termux or termux-chroot.
// See: https://github.com/sipeed/picoclaw/issues/2944
func initTermuxSSL() {
// Only applicable on Linux/Android
if runtime.GOOS != "linux" && runtime.GOOS != "android" {
return
}
// Skip if already set
if os.Getenv("SSL_CERT_FILE") != "" {
return
}
// Check for Termux prefix in PATH or HOME
home := os.Getenv("HOME")
path := os.Getenv("PATH")
isTermux := strings.Contains(home, "com.termux") ||
strings.Contains(path, "com.termux") ||
strings.Contains(home, "/data/data/com.termux")
if !isTermux {
return
}
// Check common CA bundle locations in Termux
caPaths := []string{
"$PREFIX/etc/tls/cert.pem",
os.Getenv("PREFIX") + "/etc/tls/cert.pem",
"/data/data/com.termux/files/usr/etc/tls/cert.pem",
"/usr/etc/tls/cert.pem",
}
for _, caPath := range caPaths {
expanded := os.ExpandEnv(caPath)
if _, err := os.Stat(expanded); err == nil {
os.Setenv("SSL_CERT_FILE", expanded)
return
}
}
}
func syncCliUIColor(root *cobra.Command) {
no, _ := root.PersistentFlags().GetBool("no-color")
cliui.Init(no || os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb")
@@ -123,6 +168,9 @@ const (
)
func main() {
// Initialize Termux SSL certificate detection before anything else
initTermuxSSL()
cliui.Init(earlyColorDisabled())
if earlyColorDisabled() {
+15 -1
View File
@@ -67,6 +67,11 @@
"model": "venice/venice-uncensored",
"api_keys": ["your-venice-api-key"]
},
{
"model_name": "nearai-glm",
"model": "nearai/zai-org/GLM-5.1-FP8",
"api_keys": ["your-nearai-api-key"]
},
{
"model_name": "lmstudio-local",
"model": "lmstudio/openai/gpt-oss-20b"
@@ -329,6 +334,13 @@
"base_url": "",
"max_results": 0
},
"kagi": {
"enabled": false,
"api_key": "",
"api_keys": ["YOUR_KAGI_API_KEY"],
"base_url": "https://kagi.com/api/v1/search",
"max_results": 5
},
"provider": "auto",
"sogou": {
"enabled": true,
@@ -373,7 +385,9 @@
},
"cron": {
"enabled": true,
"exec_timeout_minutes": 5
"exec_timeout_minutes": 5,
"allow_command": true,
"command_allowed_remotes": []
},
"mcp": {
"enabled": false,
+148
View File
@@ -0,0 +1,148 @@
> Back to [README](../../../README.md)
# Delta Chat Channel
PicoClaw can run as a Delta Chat bot by launching a local
`deltachat-rpc-server` process and talking to it over JSON-RPC. The RPC server
handles the email account, IMAP/SMTP connection, message store, and encryption
keys.
## Install
Install the Delta Chat RPC server. If `deltachat-rpc-server` is on `PATH`,
PicoClaw can find it automatically; otherwise set `rpc_server_path` to the
exact binary path.
```bash
pip install deltachat-rpc-server
which deltachat-rpc-server
```
Prebuilt binaries are also available from the
[Delta Chat core releases](https://github.com/deltachat/deltachat-core-rust/releases).
## Configure
The easiest setup is to let PicoClaw create a chatmail account in Delta
Chat's local account store. Put a relay marker in `email` using an empty local
part, for example `@nine.testrun.org`:
```json
{
"channel_list": {
"deltachat": {
"enabled": true,
"type": "deltachat",
"allow_from": ["friend@example.org"],
"group_trigger": {
"mention_only": true
},
"settings": {
"email": "@nine.testrun.org",
"display_name": "PicoClaw Bot",
"avatar_image": "/home/me/bot-avatar.png"
}
}
}
}
```
On startup, PicoClaw creates the account through `deltachat-rpc-server`, then
stops with an error that contains the generated address. Replace the relay
marker with that full email address and run PicoClaw again:
```json
{
"email": "bot123@nine.testrun.org",
"display_name": "PicoClaw Bot",
"avatar_image": "/home/me/bot-avatar.png"
}
```
If `email` is missing, the startup error lists the built-in relay choices copied
from Parla. You can use one of those relay markers, or a custom chatmail relay
with the same `@server.name` form.
`password` is not needed for PicoClaw-created chatmail accounts. Omit it when
`email` points to an already configured account in `data_dir`; the JSON-RPC
server owns the mailbox password. The legacy password-based path remains only
for classic email accounts that PicoClaw must configure itself. In that mode,
`password` is a secure field; on first config load it is moved to
`~/.picoclaw/.security.yml`, and it can also be set with
`PICOCLAW_CHANNELS_DELTACHAT_PASSWORD`.
`display_name` and `avatar_image` are optional profile settings. When present,
PicoClaw applies them on every startup, so changing the avatar path in config is
enough to update the bot profile.
| Field | Required | Description |
|-------|----------|-------------|
| `email` | Yes | Full bot mailbox address, or first-run relay marker such as `@nine.testrun.org` |
| `rpc_server_path` | No | Path to `deltachat-rpc-server`; only needed when it is not on `PATH` |
| `password` | No | Legacy only; required when PicoClaw must configure/reconfigure a classic mailbox itself |
| `display_name` | No | Startup-applied profile name shown to contacts and used for group mention detection |
| `avatar_image` | No | Startup-applied profile avatar image path; `~` is expanded. Missing files are warned and ignored |
| `data_dir` | No | Account database directory. Default: `~/.picoclaw/deltachat/<channel-name>` |
| `invite_link` | No | Delta Chat invite link to join on startup |
| `allow_crosspost` | No | Default `false`. When `true`, senders allowed by `allow_from` may use `message` tool targets outside the current chat, or resolve recipients by email/contact/chat name |
| `imap_server`, `imap_port` | No | Manual IMAP override for password-based configuration |
| `smtp_server`, `smtp_port` | No | Manual SMTP override for password-based configuration |
Standard channel fields such as `allow_from`, `group_trigger`, and
`reasoning_channel_id` also apply.
## First Run
With `email` set to `@server`, PicoClaw creates the chatmail account, prints
the generated full email in the startup error, and exits. Update `email` to that
full address and run PicoClaw again. On later runs, PicoClaw selects the
configured account by `email`, applies optional profile settings, marks it as a
bot, and starts IO.
With a new `data_dir` plus legacy `password`, PicoClaw can still configure a
classic email account and validate the mailbox credentials; after that, the
account is reused from the local data directory.
Delta Chat requires peers to learn the bot's encryption key before messaging
it. On startup PicoClaw prints the bot invite link and QR code. Add the bot from
Delta Chat with that invite, not by typing the bare email address.
## Behavior
- Direct chats always respond after `allow_from` passes.
- Group chats follow `group_trigger`; without one, every group message is
handled.
- Messages from the bot itself, device chats, and info/system messages are
ignored.
- Accepted inbound messages are marked seen after the allow-list check.
- Incoming attachments (images, audio, video, documents) are registered with
the media store and handed to the agent, so it can view images or operate on
the files directly. If no media store is available, the path is appended
inline as `[attachment: /path]` instead.
- Outbound attachments are supported: when the agent emits media, each file is
sent as a Delta Chat message (with the caption as text). Delta Chat infers the
view type from the file, so images, GIFs, and videos render natively.
- Crosspost recipient lookup is disabled by default. The agent can always reply
to the current numeric chat ID, but sending to another numeric chat ID or
resolving an email/contact/chat name requires `allow_crosspost: true`
and the current sender must pass `allow_from`; `allow_from: ["*"]` allows this
for any sender.
- Voice is supported in both directions when voice providers are configured:
incoming voice notes are transcribed by the agent's ASR and the transcript is
passed to the model; the agent can reply with synthesized speech, which is
delivered as a native Delta Chat voice message (`send_tts`). This requires an
ASR and/or TTS provider under `voice` — it is not Delta Chat-specific config.
## Troubleshooting
| Symptom | Fix |
|---------|-----|
| `deltachat-rpc-server not found on PATH` or `rpc_server_path ... not found` | Install the RPC server on PATH, or set `rpc_server_path` to an absolute path |
| `email is required` | Choose one listed chatmail server, set `email` to a first-run marker such as `@nine.testrun.org`, run `picoclaw g`, then replace it with the generated full email |
| `created chatmail account ...` | Replace the `@server` marker in `email` with the generated full email and run PicoClaw again |
| `account ... is not configured in data_dir` | Point `data_dir` at the existing JSON-RPC account store, or use `email="@server"` to create one |
| `configure (check email/password/server)` | Check credentials, app password requirements, or IMAP/SMTP overrides |
| Bot does not answer in a group | Check `group_trigger`; mention `display_name` or use a configured prefix |
| Bot ignores a sender | Add the sender email to `allow_from`, or use `["*"]` for open access |
| Sender cannot message the bot | Re-add the bot with the startup QR/invite so Delta Chat can establish encryption |
| Agent cannot send to an email/name/other chat ID | Enable `settings.allow_crosspost` and allow the controlling sender in `allow_from`; this capability is disabled by default for privacy |
+1
View File
@@ -3,6 +3,7 @@
Task-oriented guides for setup, configuration, and common PicoClaw workflows.
- [Docker & Quick Start Guide](docker.md): install and run PicoClaw with Docker or the launcher.
- [Android Termux Guide](android-termux.md): run the PicoClaw terminal binary on an ARM64 Android phone.
- [Configuration Guide](configuration.md): environment variables, workspace layout, routing, and sandbox settings.
- [Session Guide](session-guide.md): how session scope affects memory sharing, summaries, and isolation.
- [Routing Guide](routing-guide.md): agent dispatch, session overrides, and light-model routing.
+88
View File
@@ -0,0 +1,88 @@
# Android Termux Guide
> Back to [Guides](README.md)
This guide covers running the PicoClaw terminal binary on an ARM64 Android phone with Termux. Use the APK from [picoclaw.io](https://picoclaw.io/download/) if you want the Android app experience; use Termux when you want a lightweight command-line install on an older or resource-constrained device.
## Requirements
- ARM64 Android device. Run `uname -m` in Termux and use this guide when it prints `aarch64`.
- Termux installed from [Termux GitHub Releases](https://github.com/termux/termux-app/releases) or F-Droid.
- Network access for downloading the release and calling your LLM provider.
- An API key for at least one configured model provider.
## Install PicoClaw
Open Termux and install the packages used by the release archive and chroot wrapper:
```bash
pkg update
pkg install -y wget tar proot
```
Download and unpack the ARM64 Linux release:
```bash
mkdir -p ~/picoclaw
cd ~/picoclaw
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
tar xzf picoclaw_Linux_arm64.tar.gz
chmod +x ./picoclaw
```
Start first-run setup through `termux-chroot`, which gives the Linux binary a more standard filesystem layout than a raw Android userspace:
```bash
termux-chroot ./picoclaw onboard
```
## Configure
Edit the generated config and add at least one model provider API key:
```bash
vim ~/.picoclaw/config.json
```
The default workspace is `~/.picoclaw/workspace`. If you want PicoClaw to read or write Android shared storage, run `termux-setup-storage` first and then point the workspace or any file paths at the mounted storage directory.
See [Configuration Guide](configuration.md) and [Providers & Model Configuration](providers.md) for the available config fields and provider examples.
## Run
Use one-shot agent mode to confirm the installation:
```bash
termux-chroot ./picoclaw agent -m "Hello from Termux"
```
For long-running use, start the gateway:
```bash
termux-chroot ./picoclaw gateway
```
Keep the Termux session open while PicoClaw is running. Android battery optimization can stop background work, so disable battery optimization for Termux if you expect PicoClaw to keep running after the screen locks.
## Update
Your config and workspace live under `~/.picoclaw`, so updating the binary does not remove them:
```bash
cd ~/picoclaw
rm -f picoclaw_Linux_arm64.tar.gz
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
tar xzf picoclaw_Linux_arm64.tar.gz
chmod +x ./picoclaw
termux-chroot ./picoclaw version
```
## Troubleshooting
| Symptom | Check |
|---------|-------|
| `permission denied` | Run `chmod +x ./picoclaw` after unpacking the archive. |
| `not found` after running `./picoclaw` | Confirm `uname -m` prints `aarch64` and that you downloaded `picoclaw_Linux_arm64.tar.gz`. |
| Files or paths behave differently than Linux | Run PicoClaw through `termux-chroot` instead of calling the binary directly. |
| Provider requests fail | Check the API key and network access in `~/.picoclaw/config.json`. |
| PicoClaw stops when the phone sleeps | Disable Android battery optimization for Termux and keep a foreground Termux session active. |
+39 -2
View File
@@ -400,6 +400,7 @@ Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous
|------------|------|---------|-------------|
| `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace |
| `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace |
| `tools.message.media_enabled` | bool | `false` | Allows the `message` tool to attach local media files by path. This is separate from `tools.send_file.enabled`; enable it only when unified text/media/caption delivery is intended. |
### Read File Mode
@@ -454,7 +455,7 @@ Use `mode = lines` when:
"tools": {
"read_file": {
"enabled": true,
"mode": "lines",
"mode": "lines",
"max_read_file_size": 65536
}
}
@@ -837,6 +838,9 @@ Legacy Telegram environment variables remain compatible: `PICOCLAW_CHANNELS_TELE
Failure behavior is intentionally conservative: if streaming fails before any visible chunk is sent, PicoClaw retries once through the normal `Chat()` path. If a chunk has already been shown to the user, PicoClaw does not send a second non-streaming answer, because that would duplicate visible output.
For model-specific TTS request fields such as custom speech `voice` names or
`response_format: "mp3"`, use `model_list[].extra_body`.
#### Vendor-Specific Examples
> **Tip**: You can omit `api_key` fields and store them in `.security.yml` for better security. See [Security Configuration](#-security-configuration-recommended).
@@ -855,6 +859,35 @@ Failure behavior is intentionally conservative: if streaming fails before any vi
</details>
<details>
<summary><b>OpenRouter TTS (MAI Voice 2)</b></summary>
```json
{
"model_name": "mai-voice-2",
"provider": "openrouter",
"model": "microsoft/mai-voice-2",
"api_base": "https://openrouter.ai/api/v1",
"extra_body": {
"voice": "en-US-Harper:MAI-Voice-2",
"response_format": "mp3"
}
// api_key: set in .security.yml
}
```
Pair this with:
```json
{
"voice": {
"tts_model_name": "mai-voice-2"
}
}
```
</details>
<details>
<summary><b>VolcEngine (Doubao)</b></summary>
@@ -1121,7 +1154,9 @@ PicoClaw supports cron-style scheduled tasks via the `cron` tool. The agent can
"tools": {
"cron": {
"enabled": true,
"exec_timeout_minutes": 5
"exec_timeout_minutes": 5,
"allow_command": true,
"command_allowed_remotes": []
}
}
}
@@ -1129,6 +1164,8 @@ PicoClaw supports cron-style scheduled tasks via the `cron` tool. The agent can
Scheduled tasks persist across restarts and are stored in `~/.picoclaw/workspace/cron/`.
Command cron jobs can execute shell commands. By default, remote channels cannot schedule command jobs. To allow specific remote channels, set `command_allowed_remotes` to entries such as `"telegram"` or `"telegram:1234567890"`; use `"*"` only if every non-empty channel should be allowed. The `"*"` wildcard is potentially dangerous because any remote channel that can talk to PicoClaw can schedule shell commands. This does not bypass `allow_command`, `command_confirm`, or exec safety checks.
### Advanced Topics
| Topic | Description |
+1 -1
View File
@@ -97,7 +97,7 @@ Consumer products, routers, and industrial devices that have been tested with Pi
Any ARM64 Android phone (2015+) with 1GB+ RAM. Install [Termux](https://github.com/termux/termux-app), use `proot` to run PicoClaw.
> See [README: Run on old Android Phones](../../README.md#-run-on-old-android-phones) for setup instructions.
> See the [Android Termux Guide](android-termux.md) for setup instructions.
### Desktop / Server / Cloud
+54 -1
View File
@@ -17,6 +17,7 @@
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
| `venice` | LLM (Venice AI direct) | [venice.ai](https://venice.ai) |
| `nearai` | LLM (NEAR AI Cloud TEE inference) | [near.ai](https://near.ai) |
| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
@@ -50,6 +51,7 @@ This design also enables **multi-agent support** with flexible provider selectio
| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- |
| **OpenAI** | `openai` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
| **Venice AI** | `venice` | `https://api.venice.ai/api/v1` | OpenAI | [Get Key](https://venice.ai) |
| **NEAR AI Cloud** | `nearai` | `https://cloud-api.near.ai/v1` | OpenAI | [Get Key](https://near.ai) |
| **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
| **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
| **Z.AI Coding Plan** | `openai` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) |
@@ -138,6 +140,9 @@ This design also enables **multi-agent support** with flexible provider selectio
When streaming is disabled, omit the `streaming` block. Writing `"streaming": {"enabled": false}` is optional and not needed in generated or hand-written config.
`extra_body` is especially useful for model-specific TTS fields on OpenAI-compatible
speech routes, for example custom `voice` names or `response_format: "mp3"`.
#### Tool Schema Compatibility
By default, PicoClaw now forwards tool JSON Schemas unchanged.
@@ -205,6 +210,41 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
}
```
#### Voice Synthesis
You can configure a dedicated text-to-speech model with `voice.tts_model_name`.
When the provider needs model-specific TTS request fields, put them in
`model_list[].extra_body`.
Example with OpenRouter `microsoft/mai-voice-2`:
```json
{
"model_list": [
{
"model_name": "mai-voice-2",
"provider": "openrouter",
"model": "microsoft/mai-voice-2",
"api_base": "https://openrouter.ai/api/v1",
"extra_body": {
"voice": "en-US-Harper:MAI-Voice-2",
"response_format": "mp3"
},
"api_keys": ["sk-or-your-openrouter-key"]
}
],
"voice": {
"tts_model_name": "mai-voice-2"
}
}
```
Notes that matter:
- PicoClaw still uses the OpenAI-compatible `/audio/speech` route for this setup.
- The default TTS request uses `voice: alloy` and `response_format: opus`.
- Override those defaults with `extra_body` when your selected TTS model requires different values.
#### Vendor-Specific Examples
**OpenAI**
@@ -218,6 +258,17 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
}
```
**NEAR AI Cloud**
```json
{
"model_name": "nearai-glm",
"provider": "nearai",
"model": "zai-org/GLM-5.1-FP8",
"api_keys": ["your-nearai-api-key"]
}
```
**VolcEngine (Doubao)**
```json
@@ -609,7 +660,9 @@ picoclaw agent -m "Hello"
}
},
"cron": {
"exec_timeout_minutes": 5
"exec_timeout_minutes": 5,
"allow_command": true,
"command_allowed_remotes": []
}
},
"heartbeat": {
+58 -3
View File
@@ -26,6 +26,42 @@ picoclaw cron add --name "Daily summary" --message "Summarize today's logs" --cr
picoclaw cron add --name "Ping" --message "heartbeat" --every 300 --deliver
```
## Agent Tool Actions
The agent-facing `cron` tool supports these actions:
- `add`: create a new job.
- `list`: show accessible job names, ids, and schedules.
- `get`: fetch one accessible persisted job by `job_id`, including its saved payload.
- `update`: partially update one accessible job by `job_id`; omitted fields are preserved.
- `remove`, `enable`, `disable`: existing management actions.
When rescheduling an existing task, use `list -> get -> update`. Do not use
`remove -> add` just to change the schedule, because recreating a job can drop
the original prompt, delivery target, or command payload.
Remote channel access is scoped to the current `channel/chat_id`: remote callers
can only list, get, or update jobs whose saved `payload.channel` and `payload.to`
match the current conversation. Command jobs include a shell command payload, so
they can only be listed, inspected, or updated from internal channels or remote
channels allowed by `tools.cron.command_allowed_remotes`.
Example tool calls:
```json
{"action":"get","job_id":"79095b2f5685a0f2"}
```
```json
{"action":"update","job_id":"79095b2f5685a0f2","cron_expr":"30 10 * * *"}
```
`update` accepts `name`, `message`, `command`, and exactly one schedule field
(`at_seconds`, `every_seconds`, or `cron_expr`).
Omit `command` to preserve it, set `command` to a non-empty string to replace
it, or set `command` to `""` to clear it. Command updates require the same
channel allowlist and confirmation gates as command creation.
## Execution Modes
Jobs are stored with a message payload and can execute in three stable user-facing modes:
@@ -69,7 +105,7 @@ If `tools.exec.enabled` is `false`:
- new command jobs are rejected by the cron tool
- existing command jobs publish a `command execution is disabled` error when they fire
`tools.exec.allow_remote` is still enforced by the exec tool, but cron command scheduling already requires an internal channel when the job is created. In practice, reminder jobs can be scheduled from remote channels, while scheduled command jobs are limited to internal channels.
`tools.exec.allow_remote` is still enforced by the exec tool, but cron command scheduling has its own channel gate when the job is created. In practice, reminder jobs can be scheduled from remote channels, while scheduled command jobs are limited to internal channels and configured remote channels.
### `allow_command`
@@ -77,7 +113,23 @@ If `tools.exec.enabled` is `false`:
This is not a hard disable switch. If you set `allow_command` to `false`, PicoClaw still allows a command job when the caller explicitly passes `command_confirm: true`.
Command jobs also require an internal channel. Non-command reminders do not have that restriction.
Command jobs also require either an internal channel or a remote channel allowed by `tools.cron.command_allowed_remotes`. Non-command reminders do not have that restriction.
### `command_allowed_remotes`
`tools.cron.command_allowed_remotes` defaults to an empty list. With the default empty list, remote channels cannot schedule command jobs.
Entries can be either a channel name or a channel plus chat id:
- `telegram` allows command jobs from any Telegram chat.
- `telegram:1234567890` allows command jobs only from that exact Telegram chat id.
- `*` allows command jobs from every non-empty channel.
Warning: `*` is potentially dangerous because any remote channel that can talk
to PicoClaw can schedule shell commands. Use it only when every enabled remote
channel and chat is trusted to request command execution.
This setting only controls the remote-channel gate. It does not bypass `tools.cron.allow_command`, `command_confirm`, `tools.exec.enabled`, or the exec tool's command safety checks.
Example:
@@ -87,7 +139,10 @@ Example:
"cron": {
"enabled": true,
"exec_timeout_minutes": 5,
"allow_command": true
"allow_command": true,
"command_allowed_remotes": [
"telegram:1234567890"
]
},
"exec": {
"enabled": true
+45 -5
View File
@@ -126,6 +126,44 @@ Baidu Search uses the [Qianfan AI Search API](https://cloud.baidu.com/doc/qianfa
| `api_keys` | string[] | - | Multiple API keys for rotation (takes priority over `api_key`) |
| `max_results` | int | 5 | Maximum number of results |
### Kagi Search
Kagi Search uses the official Kagi OpenAPI client for `POST /search` and returns normal web results from `data.search`.
| Config | Type | Default | Description |
|---------------|----------|---------------------------------------|------------------------------------------------|
| `enabled` | bool | false | Enable Kagi Search |
| `api_key` | string | - | Kagi API key |
| `api_keys` | string[] | - | Multiple API keys for rotation (takes priority over `api_key`) |
| `base_url` | string | `https://kagi.com/api/v1/search` | Kagi Search API endpoint |
| `max_results` | int | 5 | Maximum number of results |
```json
{
"tools": {
"web": {
"provider": "kagi",
"kagi": {
"enabled": true,
"max_results": 5,
"base_url": "https://kagi.com/api/v1/search"
}
}
}
}
```
Store Kagi API keys in `.security.yml`:
```yaml
web:
kagi:
api_keys:
- "YOUR_KAGI_API_KEY"
```
Kagi API usage may be billed or limited separately from a normal Kagi subscription, depending on your account and API setup.
### Tavily
| Config | Type | Default | Description |
@@ -171,6 +209,7 @@ At runtime, the `web_search` tool accepts the following parameters:
| `range` | string | no | Optional time filter: `d` (day), `w` (week), `m` (month), `y` (year) |
If `range` is omitted, PicoClaw performs an unrestricted search.
For Kagi, `d`, `w`, and `m` map to Kagi lens `time_relative`; `y` maps to a lens `time_after` date one year before the current day.
### Example `web_search` Call
@@ -272,11 +311,12 @@ as containers, VMs, or an approval flow around build-and-run commands.
The cron tool is used for scheduling periodic tasks.
| Config | Type | Default | Description |
|------------------------|------|---------|------------------------------------------------|
| `enabled` | bool | true | Register the agent-facing cron tool |
| `allow_command` | bool | true | Allow command jobs without extra confirmation |
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
| Config | Type | Default | Description |
|---------------------------|----------|---------|----------------------------------------------------------------|
| `enabled` | bool | true | Register the agent-facing cron tool |
| `allow_command` | bool | true | Allow command jobs without extra confirmation |
| `command_allowed_remotes` | string[] | [] | Remote channels or `channel:chat_id` values allowed for command jobs; `*` allows every channel and is dangerous unless all remote channels are trusted |
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
For schedule types, execution modes (`deliver`, agent turn, and command jobs), persistence, and the current command-security gates, see [Scheduled Tasks and Cron Jobs](cron.md).
+14 -4
View File
@@ -248,13 +248,16 @@ channel_list:
### Web Tools
**Brave, Tavily, Perplexity:**
**Brave, Tavily, Perplexity, Kagi:**
```yaml
web:
brave:
api_keys:
- "key-1"
- "key-2"
kagi:
api_keys:
- "your-kagi-api-key"
```
- Use `api_keys` (plural) array format
@@ -315,16 +318,19 @@ model_list:
- **Rate limit management**: Distribute usage across multiple keys
- **High availability**: Reduce downtime during API provider issues
### Web Tools (Brave/Tavily/Perplexity) - Single key
### Web Tools (Brave/Tavily/Perplexity/Kagi) - Single key
```yaml
web:
brave:
api_keys:
- "BSA-your-key"
kagi:
api_keys:
- "your-kagi-api-key"
```
### Web Tools (Brave/Tavily/Perplexity) - Multiple keys
### Web Tools (Brave/Tavily/Perplexity/Kagi) - Multiple keys
```yaml
web:
@@ -332,6 +338,10 @@ web:
api_keys:
- "BSA-key-1"
- "BSA-key-2"
kagi:
api_keys:
- "kagi-key-1"
- "kagi-key-2"
```
### Web Tool (GLMSearch/BaiduSearch) - Single key only
@@ -558,7 +568,7 @@ go test ./pkg/config -run TestSecurityConfig
- Ensure you're using `api_keys` (plural) in `.security.yml` for models and web tools (except GLMSearch/BaiduSearch)
- Check that the array format is correct in YAML (proper indentation with dashes)
- Remember: Models, Brave, Tavily, Perplexity MUST use `api_keys` (array format)
- Remember: Models, Brave, Tavily, Perplexity, Kagi MUST use `api_keys` (array format)
- GLMSearch and BaiduSearch MUST use `api_key` (single string format)
### Load Balancing/Failover Issues
+49 -35
View File
@@ -1,18 +1,20 @@
module github.com/sipeed/picoclaw
go 1.25.10
go 1.25.12
require (
fyne.io/systray v1.12.1
fyne.io/systray v1.12.2
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0
github.com/SevereCloud/vksdk/v3 v3.3.1
github.com/adhocore/gronx v1.19.7
github.com/anthropics/anthropic-sdk-go v1.26.0
github.com/adhocore/gronx v1.20.0
github.com/anthropics/anthropic-sdk-go v1.61.0
github.com/atc0005/go-teams-notify/v2 v2.14.0
github.com/aws/aws-sdk-go-v2 v1.41.7
github.com/aws/aws-sdk-go-v2/config v1.32.17
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.6
github.com/aws/aws-sdk-go-v2 v1.42.0
github.com/aws/aws-sdk-go-v2/config v1.32.25
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.53.3
github.com/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.4.0
github.com/caarlos0/env/v11 v11.4.1
github.com/charmbracelet/lipgloss v1.1.0
github.com/creack/pty v1.1.24
github.com/eclipse/paho.mqtt.golang v1.5.1
@@ -22,16 +24,17 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/h2non/filetype v1.1.3
github.com/larksuite/oapi-sdk-go/v3 v3.7.5
github.com/line/line-bot-sdk-go/v8 v8.19.0
github.com/kagisearch/kagi-openapi-golang v0.0.0-20260526215348-96575e864d62
github.com/larksuite/oapi-sdk-go/v3 v3.9.4
github.com/line/line-bot-sdk-go/v8 v8.20.1
github.com/mdp/qrterminal/v3 v3.2.1
github.com/minio/selfupdate v0.6.0
github.com/modelcontextprotocol/go-sdk v1.5.0
github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/muesli/termenv v0.16.0
github.com/mymmrac/telego v1.9.0
github.com/mymmrac/telego v1.10.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0
github.com/pion/rtp v1.10.1
github.com/pion/rtp v1.10.2
github.com/pion/webrtc/v3 v3.3.6
github.com/pmezard/go-difflib v1.0.0
github.com/rs/zerolog v1.35.1
@@ -43,33 +46,37 @@ require (
go.mau.fi/util v0.9.8
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
golang.org/x/oauth2 v0.36.0
golang.org/x/term v0.43.0
golang.org/x/term v0.44.0
golang.org/x/time v0.15.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.27.0
modernc.org/sqlite v1.50.1
modernc.org/sqlite v1.53.0
rsc.io/qr v0.2.0
)
require (
aead.dev/minisign v0.2.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
github.com/aws/smithy-go v1.25.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
github.com/aws/smithy-go v1.27.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/beeper/argo-go v1.1.2 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
@@ -82,19 +89,25 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/invopop/jsonschema v0.14.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.42 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
@@ -104,9 +117,10 @@ require (
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/text v0.37.0 // indirect
modernc.org/libc v1.72.3 // indirect
golang.org/x/text v0.39.0 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
@@ -114,7 +128,7 @@ require (
require (
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.1 // indirect
github.com/bytedance/sonic v1.15.2 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/github/copilot-sdk/go v0.2.0
@@ -134,10 +148,10 @@ require (
github.com/valyala/fastjson v1.6.10 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/arch v0.24.0 // indirect
golang.org/x/crypto v0.51.0
golang.org/x/net v0.54.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.44.0
golang.org/x/crypto v0.53.0
golang.org/x/net v0.55.0
golang.org/x/sync v0.21.0
golang.org/x/sys v0.46.0
)
replace github.com/bwmarrin/discordgo => github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532
+111 -78
View File
@@ -3,68 +3,84 @@ aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ=
fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 h1:xFaZZ+IubdftrDHnGGwZ6QvQ3KHTtWl2MCK+GMt2vxs=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0/go.mod h1:mCBhUhlMjLLJKr5aqw2TNS/VqJOie8MzWq3DAMJeKso=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU=
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/SevereCloud/vksdk/v3 v3.3.1 h1:O86zsp5LQnHE+O5acvuXM/s6S1LyxzVTkF6+Lup0Jyg=
github.com/SevereCloud/vksdk/v3 v3.3.1/go.mod h1:c6WaA5aocUYsXfkcUbg2qy45V9M1VDcqHHmHIN14NAw=
github.com/adhocore/gronx v1.19.7 h1:7hhFwChgDw9eHC3+TQ+OKKBqJnP44oWkDCnnW9nrsuA=
github.com/adhocore/gronx v1.19.7/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
github.com/adhocore/gronx v1.20.0 h1:PD13Mo0wekkZ7ZZR9yb1TqeqTfybs7/K3ez9DmjQwEs=
github.com/adhocore/gronx v1.20.0/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
github.com/anthropics/anthropic-sdk-go v1.61.0 h1:JRTnm1tPqn5xo1xd1zfrcFDlcoWXVMvV1K68YmhpZKw=
github.com/anthropics/anthropic-sdk-go v1.61.0/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI=
github.com/atc0005/go-teams-notify/v2 v2.14.0 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZJED9nMLyscCo=
github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q=
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.6 h1:Wbo1WlWyGaAXlr6C7OGXq9avbdJhIV9cQ4M6E34b5x8=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.6/go.mod h1:uY1fJe6m3I3w/m8UAkQ89Cm/ZAt/um6LW+AOZU33LDI=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12 h1:oRtsqWgxbpeXrOlxOoQStx2M9WNbIkPq4C4Xn1or6bc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12/go.mod h1:Zg0Oe9qT+9wcezlm1a64wGJp2qZdRElVxo/seJf7jYU=
github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM=
github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.53.3 h1:HTzzFDJiFSNkZX1Al72+insR4dre/vUeT3YZ4b9h0MA=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.53.3/go.mod h1:dFhfMfXoFrnX6XK/gXDh+4azdybtKll2QnP239wm2O8=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8=
github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw=
github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc=
github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw=
github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
@@ -164,6 +180,12 @@ github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
github.com/kagisearch/kagi-openapi-golang v0.0.0-20260526215348-96575e864d62 h1:nyUi7Wel3KlVSa5ArgX/snlizqfaxU48qtvXS/JK5GE=
github.com/kagisearch/kagi-openapi-golang v0.0.0-20260526215348-96575e864d62/go.mod h1:vONkS+clG730HSKOw3nZVa22TjB21r6csKYzYt0a9zI=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
@@ -179,10 +201,12 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/larksuite/oapi-sdk-go/v3 v3.7.5 h1:dimv+ZAGia01f4xCDGvCiBHKWMf4K1AB7fGsM+lv5Jw=
github.com/larksuite/oapi-sdk-go/v3 v3.7.5/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/line/line-bot-sdk-go/v8 v8.19.0 h1:5FD/1SprRZ8Y0FiUI6syYiBewOs0ak2tuUBMYN0wzE4=
github.com/line/line-bot-sdk-go/v8 v8.19.0/go.mod h1:AeSRUuu7WGgveGDJb6DyKyFUOst2UB2aF6LO2cQeuXs=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/larksuite/oapi-sdk-go/v3 v3.9.4 h1:oMgcY7NBjJv1QXJqFAfcoN/TbScCkCuRZfbb1mCwZmI=
github.com/larksuite/oapi-sdk-go/v3 v3.9.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/line/line-bot-sdk-go/v8 v8.20.1 h1:OE7qwJbuZPz9MGOHHNI0E6rQNtIU7ENHyFXpvbVLwII=
github.com/line/line-bot-sdk-go/v8 v8.20.1/go.mod h1:QMXJwPka2ysSeVQKWXkBp8DzBFs+CFAXFNo75KJtWho=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
@@ -197,12 +221,12 @@ github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFe
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/mymmrac/telego v1.9.0 h1:ZUJxZaPx/1IgRvVb5lXnUB8FgW5rNYfRe6Q2EJ4OJ+Y=
github.com/mymmrac/telego v1.9.0/go.mod h1:tVEB7OqiOPx8elRk9+ETkwiDQrUhWSB2XmAKIY9KmWY=
github.com/mymmrac/telego v1.10.0 h1:Upe0TqYyiK+yE5RFXXuQWVHGfLZnqvUfj4KZVjTcgWE=
github.com/mymmrac/telego v1.10.0/go.mod h1:LsQKDA6EwssPP9XkORPXwwOFUGIRf/Wf2Wb8y3YyJdE=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
@@ -217,14 +241,18 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo=
github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE=
github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -253,6 +281,8 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI=
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -321,6 +351,8 @@ go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -330,16 +362,16 @@ golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWP
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -354,8 +386,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
@@ -365,8 +397,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -384,20 +416,21 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -405,8 +438,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -416,8 +449,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -449,20 +482,20 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.27.0 h1:yfEYwoIluVWkofUgbZl9gP4i5nQTF+QNsxtb+r5bKlM=
maunium.net/go/mautrix v0.27.0/go.mod h1:7QpEQiTy6p4LHkXXaZI+N46tGYy8HMhD0JjzZAFoFWs=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -471,8 +504,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+12
View File
@@ -0,0 +1,12 @@
package picoclaw
import "embed"
// OnboardWorkspace embeds the default onboarding workspace template.
//
// Keeping this embed at the module root lets us source files directly from the
// tracked `workspace/` tree, instead of relying on a generated copy inside
// `cmd/...` that may be absent in clean checkouts and CI lint runs.
//
//go:embed workspace
var OnboardWorkspace embed.FS
+34 -38
View File
@@ -69,8 +69,14 @@ type AgentLoop struct {
activeTurnStates sync.Map
subTurnCounter atomic.Int64
turnSeq atomic.Uint64
activeRequests sync.WaitGroup
turnSeq atomic.Uint64
// activeReqMu/activeReqCond/activeReqCount replace sync.WaitGroup to
// avoid the "WaitGroup is reused before previous Wait has returned" panic
// that occurs when Add(1) races with a goroutine-launched Wait().
activeReqMu sync.Mutex
activeReqCond *sync.Cond
activeReqCount int
reloadFunc func() error
@@ -118,6 +124,7 @@ const (
handledToolResponseSummary = "Requested output delivered via tool attachment."
sessionKeyAgentPrefix = "agent:"
pendingTurnPrefix = "pending-"
providerReloadGracePeriod = 30 * time.Second
metadataKeyMessageKind = "message_kind"
metadataKeyToolCalls = "tool_calls"
metadataKeyOutboundKind = "outbound_kind"
@@ -208,7 +215,8 @@ func (al *AgentLoop) Run(ctx context.Context) error {
// Session claimed — spawn a worker goroutine that acquires a semaphore
// slot. The goroutine is spawned immediately so the main loop keeps
// draining the inbound channel. The goroutine blocks on the semaphore.
go func(m bus.InboundMessage) {
go func(m bus.InboundMessage, ph *turnState) {
var releaseSession bool
// Acquire semaphore slot (blocks if at capacity)
select {
case al.workerSem <- struct{}{}:
@@ -216,7 +224,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
case <-ctx.Done():
// Context canceled while waiting for a slot — clean up the
// placeholder to prevent session-level deadlock.
al.activeTurnStates.Delete(sessionKey)
al.releaseSessionTurnState(sessionKey, nil)
return
}
@@ -225,16 +233,28 @@ func (al *AgentLoop) Run(ctx context.Context) error {
// completes normally, clearActiveTurn deletes the real turnState and
// this becomes a no-op (the key is already gone).
defer func() {
if releaseSession {
// Conditional delete: only remove the entry if it still points
// to our placeholder. A new message may have claimed the slot
// between the panic and this defer.
if actual, ok := al.activeTurnStates.Load(sessionKey); ok {
if ts, ok := actual.(*turnState); ok && ts == ph {
al.releaseSessionTurnState(sessionKey, ts)
}
}
return
}
if actual, ok := al.activeTurnStates.Load(sessionKey); ok {
if ts, ok := actual.(*turnState); ok && strings.HasPrefix(ts.turnID, pendingTurnPrefix) {
// Placeholder still present — runTurn never replaced it.
al.activeTurnStates.Delete(sessionKey)
al.releaseSessionTurnState(sessionKey, ts)
}
}
}()
defer func() {
if r := recover(); r != nil {
releaseSession = true
logger.RecoverPanicNoExit(r)
logger.ErrorCF("agent", "Worker goroutine panicked",
map[string]any{
@@ -252,7 +272,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
}
if al.takePendingStop(sessionKey) {
al.activeTurnStates.Delete(sessionKey)
al.releaseSessionTurnState(sessionKey, nil)
target := &continuationTarget{
SessionKey: sessionKey,
Channel: m.Channel,
@@ -270,7 +290,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
}
al.runTurnWithSteering(ctx, m)
}(msg)
}(msg, placeholder)
// TODO: Re-enable media cleanup after inbound media is properly consumed by the agent.
// Currently disabled because files are deleted before the LLM can access their content.
@@ -366,37 +386,23 @@ func (al *AgentLoop) ReloadProviderAndConfig(
return fmt.Errorf("config cannot be nil")
}
// Create new registry with updated config and provider
// Wrap in defer/recover to handle any panics gracefully
var registry *AgentRegistry
var panicErr error
done := make(chan struct{}, 1)
go func() {
func() {
defer func() {
if r := recover(); r != nil {
logger.RecoverPanicNoExit(r)
panicErr = fmt.Errorf("panic during registry creation: %v", r)
logger.ErrorCF("agent", "Panic during registry creation",
map[string]any{"panic": r})
registry = nil
}
close(done)
}()
registry = NewAgentRegistry(cfg, provider)
}()
// Wait for completion or context cancellation
select {
case <-done:
if registry == nil {
if panicErr != nil {
return fmt.Errorf("registry creation failed: %w", panicErr)
}
return fmt.Errorf("registry creation failed (nil result)")
if registry == nil {
if err := ctx.Err(); err != nil {
return fmt.Errorf("context canceled during registry creation: %w", err)
}
case <-ctx.Done():
return fmt.Errorf("context canceled during registry creation: %w", ctx.Err())
return fmt.Errorf("registry creation failed")
}
// Check context again before proceeding
@@ -472,17 +478,7 @@ func (al *AgentLoop) ReloadProviderAndConfig(
// This prevents blocking readers while closing
if oldProvider, ok := extractProvider(oldRegistry); ok {
if stateful, ok := oldProvider.(providers.StatefulProvider); ok {
// Give in-flight requests a moment to complete
// Use a reasonable timeout that balances cleanup vs resource usage
select {
case <-time.After(100 * time.Millisecond):
stateful.Close()
case <-ctx.Done():
// Context canceled, close immediately but log warning
logger.WarnCF("agent", "Context canceled during provider cleanup, forcing close",
map[string]any{"error": ctx.Err()})
stateful.Close()
}
al.closeReloadedProvider(ctx, stateful)
}
}
+16 -5
View File
@@ -333,6 +333,15 @@ func (al *AgentLoop) buildCommandsRuntime(
if opts == nil {
return fmt.Errorf("process options not available")
}
// /clear can arrive before any turn has persisted session scope
// metadata (runAgentLoop records it per turn), so record it here to
// let the ContextManager resolve which agent owns the session.
ensureSessionMetadata(
agent.Sessions,
opts.Dispatch.SessionKey,
opts.Dispatch.SessionScope,
opts.Dispatch.SessionAliases,
)
return al.contextManager.Clear(ctx, opts.SessionKey)
}
@@ -350,11 +359,13 @@ func (al *AgentLoop) buildCommandsRuntime(
}
history := agent.Sessions.GetHistory(opts.SessionKey)
return &commands.ContextStats{
UsedTokens: usage.UsedTokens,
TotalTokens: usage.TotalTokens,
CompressAtTokens: usage.CompressAtTokens,
UsedPercent: usage.UsedPercent,
MessageCount: len(history),
UsedTokens: usage.UsedTokens,
TotalTokens: usage.TotalTokens,
HistoryTokens: usage.HistoryTokens,
CompressAtTokens: usage.CompressAtTokens,
SummarizeAtTokens: usage.SummarizeAtTokens,
UsedPercent: usage.UsedPercent,
MessageCount: len(history),
}
}
}
+39 -5
View File
@@ -5,6 +5,7 @@ package agent
import (
"context"
"fmt"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/agent/interfaces"
@@ -91,6 +92,7 @@ func NewAgentLoop(
})
}
}
al.activeReqCond = sync.NewCond(&al.activeReqMu)
al.refreshRuntimeEventLogger(cfg)
al.providerFactory = providers.CreateProviderFromConfig
al.hooks = NewHookManager(al.runtimeEvents.Channel())
@@ -161,26 +163,58 @@ func registerSharedTools(
// Message tool
if cfg.Tools.IsToolEnabled("message") {
messageTool := tools.NewMessageTool()
if cfg.Tools.Message.MediaEnabled {
messageTool.ConfigureLocalMedia(
agent.Workspace,
cfg.Agents.Defaults.RestrictToWorkspace,
cfg.Agents.Defaults.GetMaxMediaSize(),
allowReadPaths,
)
}
messageTool.SetSendCallback(func(
ctx context.Context,
channel, chatID, content, replyToMessageID string,
mediaParts []bus.MediaPart,
) error {
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
outboundCtx := bus.NewOutboundContext(channel, chatID, replyToMessageID)
outboundAgentID, outboundSessionKey, outboundScope := outboundTurnMetadata(
tools.ToolAgentID(ctx),
tools.ToolSessionKey(ctx),
tools.ToolSessionScope(ctx),
)
return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
if len(mediaParts) > 0 {
outboundMedia := bus.OutboundMediaMessage{
Channel: channel,
ChatID: chatID,
Context: outboundCtx,
AgentID: outboundAgentID,
SessionKey: outboundSessionKey,
Scope: outboundScope,
Parts: mediaParts,
}
if al.channelManager != nil && channel != "" {
return al.channelManager.SendMedia(ctx, outboundMedia)
}
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
return msgBus.PublishOutboundMedia(pubCtx, outboundMedia)
}
outboundMessage := bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Context: outboundCtx,
AgentID: outboundAgentID,
SessionKey: outboundSessionKey,
Scope: outboundScope,
Content: content,
ReplyToMessageID: replyToMessageID,
})
}
if al.channelManager != nil && channel != "" {
return al.channelManager.SendMessage(ctx, outboundMessage)
}
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
return msgBus.PublishOutbound(pubCtx, outboundMessage)
})
agent.Tools.Register(messageTool)
}
@@ -264,7 +298,7 @@ func registerSharedTools(
// This keeps subagent vision support working even when the optimized
// sub-turn spawner path is unavailable.
subagentManager.SetMediaResolver(func(msgs []providers.Message) []providers.Message {
return resolveMediaRefs(msgs, al.mediaStore, cfg.Agents.Defaults.GetMaxMediaSize())
return resolveMediaRefs(msgs, al.mediaStore, cfg.Agents.Defaults.GetMaxMediaSize(), 0)
})
// Set the spawner that links into AgentLoop's turnState
+46 -22
View File
@@ -31,6 +31,21 @@ var (
filePlaceholderRegex = regexp.MustCompile(`\[file(:\s+[^\]]*)?\]`)
)
func normalizeCurrentTurnStart(messages []providers.Message, currentTurnStart int) int {
if currentTurnStart < 0 {
return 0
}
if currentTurnStart > len(messages) {
return len(messages)
}
return currentTurnStart
}
func currentTurnMessages(messages []providers.Message, currentTurnStart int) []providers.Message {
currentTurnStart = normalizeCurrentTurnStart(messages, currentTurnStart)
return messages[currentTurnStart:]
}
// resolveMediaRefs resolves media:// refs in messages.
// For user messages: images get path tags only ([image:/path]) so the LLM
// can decide whether to view them via load_image or operate on the file.
@@ -38,12 +53,20 @@ var (
// user message only after the contiguous tool-message block ends, so we don't
// break the tool-results-must-immediately-follow-assistant constraint that
// LLM APIs enforce.
// Only tool messages from the current turn may emit the synthetic user
// follow-up; historical tool results stay as plain path-tagged history.
// Non-image files always get path tags regardless of role.
// Returns a new slice; original messages are not mutated.
func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message {
func resolveMediaRefs(
messages []providers.Message,
store media.MediaStore,
maxSize int,
currentTurnStart int,
) []providers.Message {
if store == nil {
return messages
}
currentTurnStart = normalizeCurrentTurnStart(messages, currentTurnStart)
result := make([]providers.Message, 0, len(messages))
var pendingToolImages []string
@@ -52,22 +75,14 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
// When leaving a tool-message block, flush any accumulated images
// as a synthetic user message.
if m.Role != "tool" && len(pendingToolImages) > 0 {
result = append(result, providers.Message{
Role: "user",
Content: "[Loaded image from tool result above]",
Media: pendingToolImages,
})
result = append(result, toolImageFollowUpPromptMessage(pendingToolImages))
pendingToolImages = nil
}
if len(m.Media) == 0 {
result = append(result, m)
if idx == len(messages)-1 && len(pendingToolImages) > 0 {
result = append(result, providers.Message{
Role: "user",
Content: "[Loaded image from tool result above]",
Media: pendingToolImages,
})
result = append(result, toolImageFollowUpPromptMessage(pendingToolImages))
pendingToolImages = nil
}
continue
@@ -85,10 +100,15 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
localPath, meta, err := store.ResolveWithMeta(ref)
if err != nil {
logger.WarnCF("agent", "Failed to resolve media ref", map[string]any{
fields := map[string]any{
"ref": ref,
"error": err.Error(),
})
}
if idx < currentTurnStart {
logger.DebugCF("agent", "Skipped stale historical media ref", fields)
} else {
logger.WarnCF("agent", "Failed to resolve media ref", fields)
}
continue
}
@@ -104,7 +124,7 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
mime := detectMIME(localPath, meta)
pathTags = append(pathTags, buildPathTag(mime, localPath))
if m.Role == "tool" && strings.HasPrefix(mime, "image/") {
if m.Role == "tool" && idx >= currentTurnStart && strings.HasPrefix(mime, "image/") {
dataURL := encodeImageToDataURL(localPath, mime, info, maxSize)
if dataURL != "" {
pendingToolImages = append(pendingToolImages, dataURL)
@@ -120,11 +140,7 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
// If this is the last message and we have pending images, flush them.
if idx == len(messages)-1 && len(pendingToolImages) > 0 {
result = append(result, providers.Message{
Role: "user",
Content: "[Loaded image from tool result above]",
Media: pendingToolImages,
})
result = append(result, toolImageFollowUpPromptMessage(pendingToolImages))
pendingToolImages = nil
}
}
@@ -161,14 +177,22 @@ func encodeImageToDataURL(localPath, mime string, info os.FileInfo, maxSize int)
buf.WriteString(prefix)
encoder := base64.NewEncoder(base64.StdEncoding, &buf)
if _, err := io.Copy(encoder, f); err != nil {
_, copyErr := io.Copy(encoder, f)
closeErr := encoder.Close()
if copyErr != nil {
logger.WarnCF("agent", "Failed to encode media file", map[string]any{
"path": localPath,
"error": err.Error(),
"error": copyErr.Error(),
})
return ""
}
if closeErr != nil {
logger.WarnCF("agent", "Failed to close base64 encoder", map[string]any{
"path": localPath,
"error": closeErr.Error(),
})
return ""
}
encoder.Close()
return buf.String()
}
+1 -2
View File
@@ -6,7 +6,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
@@ -21,7 +20,7 @@ func (al *AgentLoop) maybePublishError(ctx context.Context, channel, chatID, ses
if errors.Is(err, context.Canceled) {
return false
}
al.PublishResponseIfNeeded(ctx, channel, chatID, sessionKey, fmt.Sprintf("Error processing message: %v", err))
al.PublishResponseIfNeeded(ctx, channel, chatID, sessionKey, formatProcessingError(err))
return true
}
+1160 -40
View File
File diff suppressed because it is too large Load Diff
+72
View File
@@ -13,6 +13,7 @@ import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/commands"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/utils"
@@ -591,6 +592,77 @@ func closeProviderIfStateful(provider providers.LLMProvider) {
}
}
// activeRequestsInc atomically increments the active request count.
func (al *AgentLoop) activeRequestsInc() {
al.activeReqMu.Lock()
al.activeReqCount++
al.activeReqMu.Unlock()
}
// activeRequestsDec atomically decrements the active request count
// and wakes any goroutine blocked in waitForActiveRequests when the
// count reaches zero.
func (al *AgentLoop) activeRequestsDec() {
al.activeReqMu.Lock()
al.activeReqCount--
if al.activeReqCount == 0 {
al.activeReqCond.Broadcast()
}
al.activeReqMu.Unlock()
}
func (al *AgentLoop) waitForActiveRequests(ctx context.Context, timeout time.Duration) bool {
al.activeReqMu.Lock()
if al.activeReqCount == 0 {
al.activeReqMu.Unlock()
return true
}
// Wake blocked Wait() callers on timeout or context cancellation.
var timedOut bool
if timeout > 0 {
time.AfterFunc(timeout, func() {
al.activeReqMu.Lock()
timedOut = true
al.activeReqCond.Broadcast()
al.activeReqMu.Unlock()
})
}
go func() {
<-ctx.Done()
al.activeReqMu.Lock()
al.activeReqCond.Broadcast()
al.activeReqMu.Unlock()
}()
for al.activeReqCount > 0 && !timedOut && ctx.Err() == nil {
al.activeReqCond.Wait()
}
result := al.activeReqCount == 0
al.activeReqMu.Unlock()
return result
}
func (al *AgentLoop) closeReloadedProvider(ctx context.Context, provider providers.StatefulProvider) {
waitCtx := ctx
if waitCtx == nil {
waitCtx = context.Background()
}
drained := al.waitForActiveRequests(waitCtx, providerReloadGracePeriod)
if !drained {
fields := map[string]any{"grace_period": providerReloadGracePeriod.String()}
if err := waitCtx.Err(); err != nil {
fields["error"] = err.Error()
logger.WarnCF("agent", "Provider reload interrupted while waiting for in-flight requests", fields)
} else {
logger.WarnCF("agent", "Provider reload grace period expired with in-flight requests still running", fields)
}
}
provider.Close()
}
func makePendingTurnID(sessionKey string, seq uint64) string {
return pendingTurnPrefix + sessionKey + "-" + fmt.Sprintf("%d", seq)
}
+7 -1
View File
@@ -102,7 +102,13 @@ func NewContextBuilder(workspace string) *ContextBuilder {
// Use the skills/ directory under the current working directory
builtinSkillsDir := strings.TrimSpace(os.Getenv(config.EnvBuiltinSkills))
if builtinSkillsDir == "" {
wd, _ := os.Getwd()
wd, err := os.Getwd()
if err != nil {
// os.Getwd failure is extremely rare; fall back to empty
// string so that filepath.Join produces a relative "skills"
// path, preserving the original lookup behavior.
wd = ""
}
builtinSkillsDir = filepath.Join(wd, "skills")
}
globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills")
+52
View File
@@ -115,3 +115,55 @@ func isOverContextBudget(
return total > contextWindow
}
// trimHistoryToFitContextWindow rebuilds the prompt from progressively newer
// history slices until it fits within the context window. Oldest complete turns
// are dropped first so tool-call sequences remain intact.
func trimHistoryToFitContextWindow(
history []providers.Message,
build func([]providers.Message) []providers.Message,
contextWindow int,
toolDefs []providers.ToolDefinition,
maxTokens int,
) ([]providers.Message, []providers.Message, bool) {
messages := build(history)
if !isOverContextBudget(contextWindow, messages, toolDefs, maxTokens) {
return history, messages, true
}
trimmedHistory := append([]providers.Message(nil), history...)
for len(trimmedHistory) > 0 {
dropUntil := nextHistoryTrimStart(trimmedHistory)
if dropUntil <= 0 || dropUntil >= len(trimmedHistory) {
trimmedHistory = nil
} else {
trimmedHistory = append([]providers.Message(nil), trimmedHistory[dropUntil:]...)
}
messages = build(trimmedHistory)
if !isOverContextBudget(contextWindow, messages, toolDefs, maxTokens) {
return trimmedHistory, messages, true
}
}
return nil, messages, false
}
func nextHistoryTrimStart(history []providers.Message) int {
if len(history) == 0 {
return 0
}
turns := parseTurnBoundaries(history)
if len(turns) >= 2 {
return turns[1]
}
if len(turns) == 1 {
if turns[0] > 0 {
return turns[0]
}
return len(history)
}
return len(history)
}
+61
View File
@@ -844,3 +844,64 @@ func TestIsOverContextBudget_RealisticSession(t *testing.T) {
t.Error("realistic session should exceed 500 context window")
}
}
func TestTrimHistoryToFitContextWindow_DropsOldestTurns(t *testing.T) {
history := []providers.Message{
msgUser(strings.Repeat("u1 ", 120)),
msgAssistant(strings.Repeat("a1 ", 120)),
msgUser(strings.Repeat("u2 ", 120)),
msgAssistant(strings.Repeat("a2 ", 120)),
msgUser(strings.Repeat("u3 ", 120)),
msgAssistant(strings.Repeat("a3 ", 120)),
}
build := func(history []providers.Message) []providers.Message {
return append([]providers.Message(nil), history...)
}
trimmedHistory, messages, fit := trimHistoryToFitContextWindow(
history,
build,
700,
nil,
0,
)
if !fit {
t.Fatal("expected trimmed history to fit context window")
}
if len(trimmedHistory) != 4 {
t.Fatalf("trimmed history len = %d, want 4", len(trimmedHistory))
}
if trimmedHistory[0].Content != history[2].Content {
t.Fatalf("first kept message = %q, want second turn start", trimmedHistory[0].Content)
}
if isOverContextBudget(700, messages, nil, 0) {
t.Fatal("trimmed messages should be within budget")
}
}
func TestTrimHistoryToFitContextWindow_ClearsSingleOversizedTurn(t *testing.T) {
history := []providers.Message{
msgUser(strings.Repeat("oversized ", 200)),
msgAssistant(strings.Repeat("oversized ", 200)),
}
trimmedHistory, messages, fit := trimHistoryToFitContextWindow(
history,
func(history []providers.Message) []providers.Message {
return append([]providers.Message(nil), history...)
},
200,
nil,
0,
)
if !fit {
t.Fatal("expected empty history rebuild to fit context window")
}
if len(trimmedHistory) != 0 {
t.Fatalf("trimmed history len = %d, want 0", len(trimmedHistory))
}
if len(messages) != 0 {
t.Fatalf("messages len = %d, want 0", len(messages))
}
}
+5 -3
View File
@@ -63,7 +63,9 @@ func (m *legacyContextManager) Ingest(_ context.Context, _ *IngestRequest) error
}
func (m *legacyContextManager) Clear(_ context.Context, sessionKey string) error {
agent := m.al.registry.GetDefaultAgent()
// Routed (non-default) agents keep history in their own session store,
// so resolve the owning agent instead of assuming the default one.
agent := m.al.agentForSession(sessionKey)
if agent == nil || agent.Sessions == nil {
return fmt.Errorf("sessions not initialized")
}
@@ -294,9 +296,9 @@ func (m *legacyContextManager) retryLLMCall(
var err error
for attempt := 0; attempt < maxRetries; attempt++ {
m.al.activeRequests.Add(1)
m.al.activeRequestsInc()
resp, err = func() (*providers.LLMResponse, error) {
defer m.al.activeRequests.Done()
defer m.al.activeRequestsDec()
return agent.Provider.Chat(
ctx,
[]providers.Message{{Role: "user", Content: prompt}},
+95 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
@@ -658,6 +659,91 @@ func TestIngestCalledDuringTurn(t *testing.T) {
}
}
func TestClearCommandRoutedAgentCallsContextManagerClear(t *testing.T) {
cleanup := resetCMRegistry()
defer cleanup()
mock := &trackingContextManager{}
factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) {
return mock, nil
}
if err := RegisterContextManager("clear_track_cm", factory); err != nil {
t.Fatalf("register failed: %v", err)
}
workspace := t.TempDir()
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: filepath.Join(workspace, "default"),
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
ContextManager: "clear_track_cm",
},
List: []config.AgentConfig{
{
ID: "main",
Default: true,
Workspace: filepath.Join(workspace, "main"),
},
{
ID: "support",
Workspace: filepath.Join(workspace, "support"),
},
},
Dispatch: &config.DispatchConfig{
Rules: []config.DispatchRule{
{
Name: "support-dingtalk",
Agent: "support",
When: config.DispatchSelector{
Channel: "dingtalk",
},
},
},
},
},
Session: config.SessionConfig{
Dimensions: []string{"chat"},
},
}
al := NewAgentLoop(cfg, bus.NewMessageBus(), &simpleMockProvider{response: "done"})
if al.contextManager != mock {
t.Fatalf("expected mock context manager, got %T", al.contextManager)
}
msg := testInboundMessage(bus.InboundMessage{
Context: bus.InboundContext{
Channel: "dingtalk",
ChatID: "chat1",
ChatType: "direct",
SenderID: "user1",
},
Content: "/clear",
})
route, _, err := al.resolveMessageRoute(msg)
if err != nil {
t.Fatalf("resolveMessageRoute() error = %v", err)
}
sessionKey := al.allocateRouteSession(route, msg).SessionKey
if _, err := al.processMessage(context.Background(), msg); err != nil {
t.Fatalf("processMessage() error = %v", err)
}
if got := mock.clearCalls.Load(); got != 1 {
t.Fatalf("Clear calls = %d, want 1", got)
}
mock.mu.Lock()
gotKey := mock.lastClearKey
mock.mu.Unlock()
if gotKey != sessionKey {
t.Fatalf("Clear session key = %q, want %q", gotKey, sessionKey)
}
}
// ---------------------------------------------------------------------------
// forceCompression edge cases (via legacy Compact)
// ---------------------------------------------------------------------------
@@ -712,10 +798,12 @@ type trackingContextManager struct {
assembleCalls atomic.Int64
compactCalls atomic.Int64
ingestCalls atomic.Int64
clearCalls atomic.Int64
mu sync.Mutex
lastAssemble *AssembleRequest
lastCompact *CompactRequest
lastIngest *IngestRequest
lastClearKey string
}
func (m *trackingContextManager) Assemble(_ context.Context, req *AssembleRequest) (*AssembleResponse, error) {
@@ -742,7 +830,13 @@ func (m *trackingContextManager) Ingest(_ context.Context, req *IngestRequest) e
return nil
}
func (m *trackingContextManager) Clear(_ context.Context, _ string) error { return nil }
func (m *trackingContextManager) Clear(_ context.Context, sessionKey string) error {
m.clearCalls.Add(1)
m.mu.Lock()
m.lastClearKey = sessionKey
m.mu.Unlock()
return nil
}
// resetCMRegistry clears the global factory registry and returns a cleanup
// function that restores the original state after the test.
+23 -4
View File
@@ -6,6 +6,7 @@ import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
@@ -19,6 +20,7 @@ import (
type seahorseContextManager struct {
engine *seahorse.Engine
sessions session.SessionStore // for startup bootstrap
al *AgentLoop // for resolving the agent that owns a session
}
// newSeahorseContextManager creates a seahorse-backed ContextManager.
@@ -46,6 +48,7 @@ func newSeahorseContextManager(_ json.RawMessage, al *AgentLoop) (ContextManager
mgr := &seahorseContextManager{
engine: engine,
sessions: agent.Sessions,
al: al,
}
// Register seahorse tools with the agent's tool registry
@@ -159,10 +162,18 @@ func (m *seahorseContextManager) Clear(ctx context.Context, sessionKey string) e
if err := m.engine.ClearSession(ctx, sessionKey); err != nil {
return err
}
if m.sessions != nil {
m.sessions.SetHistory(sessionKey, []providers.Message{})
m.sessions.SetSummary(sessionKey, "")
return m.sessions.Save(sessionKey)
// The session may belong to a routed (non-default) agent whose JSONL
// store differs from the bootstrap store, so clear the owner's store.
sessions := m.sessions
if m.al != nil {
if agent := m.al.agentForSession(sessionKey); agent != nil && agent.Sessions != nil {
sessions = agent.Sessions
}
}
if sessions != nil {
sessions.SetHistory(sessionKey, []providers.Message{})
sessions.SetSummary(sessionKey, "")
return sessions.Save(sessionKey)
}
return nil
}
@@ -200,6 +211,7 @@ func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message {
ModelName: msg.ModelName,
ReasoningContent: msg.ReasoningContent,
TokenCount: tokenizer.EstimateMessageTokens(msg),
CreatedAt: normalizeSeahorseMessageCreatedAt(msg.CreatedAt),
}
// Convert ToolCalls → MessageParts
@@ -235,6 +247,13 @@ func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message {
return result
}
func normalizeSeahorseMessageCreatedAt(createdAt *time.Time) time.Time {
if createdAt == nil || createdAt.IsZero() {
return time.Time{}
}
return createdAt.UTC().Truncate(time.Second)
}
// seahorseToProviderMessages converts a seahorse.AssembleResult to []providers.Message.
func seahorseToProviderMessages(result *seahorse.AssembleResult) []protocoltypes.Message {
messages := make([]protocoltypes.Message, 0, len(result.Messages))
+73
View File
@@ -171,11 +171,13 @@ func TestProviderToSeahorseMessageWithMedia(t *testing.T) {
}
func TestProviderToSeahorseMessageWithReasoning(t *testing.T) {
createdAt := time.Date(2026, 5, 6, 7, 8, 9, 123000000, time.UTC)
msg := protocoltypes.Message{
Role: "assistant",
Content: "response text",
ModelName: "gpt-5.4-mini",
ReasoningContent: "I thought about this carefully",
CreatedAt: &createdAt,
}
result := providerToSeahorseMessage(msg)
@@ -185,6 +187,9 @@ func TestProviderToSeahorseMessageWithReasoning(t *testing.T) {
if result.ModelName != "gpt-5.4-mini" {
t.Errorf("ModelName = %q, want %q", result.ModelName, "gpt-5.4-mini")
}
if !result.CreatedAt.Equal(time.Date(2026, 5, 6, 7, 8, 9, 0, time.UTC)) {
t.Errorf("CreatedAt = %v, want 2026-05-06 07:08:09 UTC", result.CreatedAt)
}
}
func TestSeahorseToProviderMessagesWithReasoning(t *testing.T) {
@@ -288,6 +293,74 @@ func TestSeahorseToProviderMessagesWithToolCalls(t *testing.T) {
}
}
func TestSeahorseAssemblePreservesActiveToolTurnAcrossSanitization(t *testing.T) {
engine, err := seahorse.NewEngine(seahorse.Config{
DBPath: t.TempDir() + "/seahorse.db",
}, nil)
if err != nil {
t.Fatalf("NewEngine: %v", err)
}
ctx := context.Background()
sessionKey := "test:active-tool-turn"
_, err = engine.Ingest(ctx, sessionKey, []seahorse.Message{
{
Role: "assistant",
Content: "older context",
TokenCount: 20,
},
{
Role: "user",
Content: "inspect the file",
TokenCount: 5,
},
{
Role: "assistant",
TokenCount: 5,
Parts: []seahorse.MessagePart{{
Type: "tool_use",
Name: "read_file",
Arguments: `{"path":"/tmp/test.txt"}`,
ToolCallID: "tc_1",
}},
},
{
Role: "tool",
TokenCount: 200,
Parts: []seahorse.MessagePart{{
Type: "tool_result",
ToolCallID: "tc_1",
Text: "very large tool output",
}},
},
{
Role: "assistant",
Content: "done",
TokenCount: 5,
},
})
if err != nil {
t.Fatalf("Ingest: %v", err)
}
result, err := engine.Assemble(ctx, sessionKey, seahorse.AssembleInput{Budget: 210})
if err != nil {
t.Fatalf("Assemble: %v", err)
}
sanitized := sanitizeHistoryForProvider(seahorseToProviderMessages(result))
if len(sanitized) != 4 {
t.Fatalf("sanitized history len = %d, want 4 protected-turn messages", len(sanitized))
}
assertRoles(t, sanitized, "user", "assistant", "tool", "assistant")
if len(sanitized[1].ToolCalls) != 1 || sanitized[1].ToolCalls[0].ID != "tc_1" {
t.Fatalf("assistant tool calls = %+v, want preserved tool call tc_1", sanitized[1].ToolCalls)
}
if sanitized[2].ToolCallID != "tc_1" {
t.Fatalf("tool result id = %q, want tc_1", sanitized[2].ToolCallID)
}
}
func TestSeahorseToProviderMessagesToolResult(t *testing.T) {
msg := seahorse.Message{
Role: "tool",
+17 -4
View File
@@ -61,6 +61,17 @@ func computeContextUsage(agent *AgentInstance, sessionKey string) *bus.ContextUs
// proactive trigger (msgTokens + toolTokens + maxTokens > contextWindow).
compressAt := effectiveWindow
// summarizeAt = soft summarization trigger: matches maybeSummarize's
// threshold (contextWindow * SummarizeTokenPercent / 100).
//
// The engine compares this against history-message tokens ONLY (not
// UsedTokens). HistoryTokens is exposed alongside UsedTokens so the
// UI can show both values and avoid user confusion.
summarizeAt := contextWindow * agent.SummarizeTokenPercent / 100
if summarizeAt <= 0 {
summarizeAt = compressAt
}
usedPercent := 0
if compressAt > 0 {
usedPercent = usedTokens * 100 / compressAt
@@ -70,9 +81,11 @@ func computeContextUsage(agent *AgentInstance, sessionKey string) *bus.ContextUs
}
return &bus.ContextUsage{
UsedTokens: usedTokens,
TotalTokens: contextWindow,
CompressAtTokens: compressAt,
UsedPercent: usedPercent,
UsedTokens: usedTokens,
TotalTokens: contextWindow,
HistoryTokens: historyTokens,
CompressAtTokens: compressAt,
SummarizeAtTokens: summarizeAt,
UsedPercent: usedPercent,
}
}
+36
View File
@@ -0,0 +1,36 @@
package agent
import (
"fmt"
"github.com/sipeed/picoclaw/pkg/providers"
)
func formatProcessingError(err error) string {
if err == nil {
return ""
}
if kind, ok := providers.ClassifyAuthError(err); ok {
return fmt.Sprintf(
"Error processing message: %s\n\nOriginal error:\n%s",
authErrorFriendlyMessage(kind),
err.Error(),
)
}
return fmt.Sprintf("Error processing message: %v", err)
}
func authErrorFriendlyMessage(kind providers.AuthErrorKind) string {
switch kind {
case providers.AuthErrorInvalidAPIKey:
return "Authentication failed: the API key appears to be invalid. Check the API key configured for this model or provider."
case providers.AuthErrorMissingAPIKey:
return "Authentication failed: no API key is configured for this model or provider. Add an API key in the model settings or config."
case providers.AuthErrorExpiredToken:
return "Authentication failed: the saved login or token appears to be expired. Re-authenticate the provider."
default:
return "Authentication failed: check the API key, token, OAuth login, or provider permissions for this model."
}
}
+52
View File
@@ -0,0 +1,52 @@
package agent
import (
"errors"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/providers/common"
)
func TestFormatProcessingError_InvalidAPIKey(t *testing.T) {
err := errors.New(
`LLM call failed after retries: API request failed: Status: 401 Body: {"error":{"message":"Incorrect API key provided"}}`,
)
got := formatProcessingError(err)
if !strings.Contains(got, "API key appears to be invalid") {
t.Fatalf("formatted error missing friendly API key hint: %q", got)
}
if !strings.Contains(got, "Original error:") {
t.Fatalf("formatted error missing original error label: %q", got)
}
if !strings.Contains(got, err.Error()) {
t.Fatalf("formatted error missing original error: %q", got)
}
}
func TestFormatProcessingError_GenericAuthHTTPError(t *testing.T) {
err := &common.HTTPError{
StatusCode: 401,
BodyPreview: `{"error":"unauthorized"}`,
ContentType: "application/json",
APIBase: "https://api.example.com",
}
got := formatProcessingError(err)
if !strings.Contains(got, "check the API key, token, OAuth login, or provider permissions") {
t.Fatalf("formatted error missing generic auth hint: %q", got)
}
if !strings.Contains(got, "Original error:") {
t.Fatalf("formatted error missing original error: %q", got)
}
}
func TestFormatProcessingError_NonAuth(t *testing.T) {
err := errors.New("connection reset by peer")
got := formatProcessingError(err)
want := "Error processing message: connection reset by peer"
if got != want {
t.Fatalf("formatted error = %q, want %q", got, want)
}
}
+7
View File
@@ -202,6 +202,9 @@ func (b *evolutionBridge) handleTurnEndAsync(meta EventMeta, payload TurnEndPayl
FinalSuccessfulPath: append([]string(nil), payload.FinalSuccessfulPath...),
SkillContextSnapshots: toEvolutionSkillContextSnapshots(payload.SkillContextSnapshots),
}
if isEvolutionHeartbeatInput(input) {
return false
}
b.rememberScheduledColdPathWorkspace(input.Workspace)
b.closeMu.Lock()
@@ -228,6 +231,10 @@ func (b *evolutionBridge) handleTurnEndAsync(meta EventMeta, payload TurnEndPayl
return true
}
func isEvolutionHeartbeatInput(input evolution.TurnCaseInput) bool {
return strings.EqualFold(strings.TrimSpace(input.SessionKey), "heartbeat")
}
func (b *evolutionBridge) subscribeRuntimeEvents(ch runtimeevents.EventChannel) error {
if b == nil || ch == nil {
return nil
+25
View File
@@ -513,6 +513,31 @@ func TestEvolutionBridge_DraftModeAutomaticallyRunsColdPathAndCreatesDraftFile(t
waitForDrafts(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"), 1)
}
func TestEvolutionBridge_DraftModeDoesNotRunColdPathForHeartbeat(t *testing.T) {
tmpDir := t.TempDir()
seedReadyRule(t, tmpDir)
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
Enabled: true,
Mode: "draft",
}, &simpleMockProvider{
response: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"append","human_summary":"unexpected heartbeat draft","body_or_patch":"## Unexpected"}`,
})
defer al.Close()
resp, err := al.ProcessHeartbeat(context.Background(), "check heartbeat tasks", "telegram", "chat-1")
if err != nil {
t.Fatalf("ProcessHeartbeat failed: %v", err)
}
if resp == "" {
t.Fatal("expected non-empty heartbeat response")
}
time.Sleep(150 * time.Millisecond)
assertNotExists(t, filepath.Join(tmpDir, "state", "evolution", "task-records.jsonl"))
assertNotExists(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"))
}
func TestEvolutionBridge_ScheduledModeDoesNotRunColdPathAfterTurn(t *testing.T) {
tmpDir := t.TempDir()
seedReadyRule(t, tmpDir)
+34 -10
View File
@@ -2,7 +2,6 @@ package agent
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
@@ -44,6 +43,7 @@ type AgentInstance struct {
SkillsFilter []string
MCPServerAllowlist map[string]struct{}
Candidates []providers.FallbackCandidate
ImageCandidates []providers.FallbackCandidate
// Router is non-nil when model routing is configured and the light model
// was successfully resolved. It scores each incoming message and decides
@@ -103,8 +103,25 @@ func NewAgentInstance(
toolsRegistry.Register(tools.NewReadFileBytesTool(workspace, readRestrict, maxReadFileSize, allowReadPaths))
}
}
if cfg.Tools.IsToolEnabled("edit_file") {
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
}
if cfg.Tools.IsToolEnabled("append_file") {
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths))
}
// Build write_file's copy from the registered editors so it steers the agent
// to edit_file/append_file only when those tools are actually available.
if cfg.Tools.IsToolEnabled("write_file") {
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
writeTool := tools.NewWriteFileTool(workspace, restrict, allowWritePaths)
var altTools []string
if toolsRegistry.HasRegistered("append_file") {
altTools = append(altTools, "append_file")
}
if toolsRegistry.HasRegistered("edit_file") {
altTools = append(altTools, "edit_file")
}
writeTool.SetAlternativeTools(altTools)
toolsRegistry.Register(writeTool)
}
if cfg.Tools.IsToolEnabled("list_dir") {
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
@@ -119,13 +136,6 @@ func NewAgentInstance(
}
}
if cfg.Tools.IsToolEnabled("edit_file") {
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
}
if cfg.Tools.IsToolEnabled("append_file") {
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths))
}
sessionsDir := filepath.Join(workspace, "sessions")
sessions := initSessionStore(sessionsDir)
@@ -199,9 +209,19 @@ func NewAgentInstance(
// Resolve fallback candidates
candidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks)
imageCandidates := resolveModelCandidates(
cfg,
defaults.Provider,
defaults.ImageModel,
defaults.ImageModelFallbacks,
)
candidateProviders := make(map[string]providers.LLMProvider)
populateCandidateProvidersFromNames(cfg, workspace, fallbacks, candidateProviders)
if strings.TrimSpace(defaults.ImageModel) != "" {
imageNames := append([]string{defaults.ImageModel}, defaults.ImageModelFallbacks...)
populateCandidateProvidersFromNames(cfg, workspace, imageNames, candidateProviders)
}
// Model routing setup: pre-resolve light model candidates at creation time
// to avoid repeated model_list lookups on every incoming message.
@@ -266,6 +286,7 @@ func NewAgentInstance(
SkillsFilter: skillsFilter,
MCPServerAllowlist: agentMCPServerAllowlist,
Candidates: candidates,
ImageCandidates: imageCandidates,
Router: router,
LightCandidates: lightCandidates,
LightProvider: lightProvider,
@@ -414,7 +435,10 @@ func compilePatterns(patterns []string) []*regexp.Regexp {
for _, p := range patterns {
re, err := regexp.Compile(p)
if err != nil {
fmt.Printf("Warning: invalid path pattern %q: %v\n", p, err)
logger.WarnCF("agent", "invalid path pattern in compilePatterns", map[string]any{
"pattern": p,
"error": err.Error(),
})
continue
}
compiled = append(compiled, re)
+96
View File
@@ -584,6 +584,102 @@ func TestNewAgentInstance_ReadFileModeSelectsSchema(t *testing.T) {
}
}
// write_file copy names append_file/edit_file only when they are registered.
func TestNewAgentInstance_WriteFileCopyReflectsAvailableAltTools(t *testing.T) {
newCfg := func(editEnabled, appendEnabled bool) *config.Config {
return &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: t.TempDir(),
ModelName: "test-model",
},
},
Tools: config.ToolsConfig{
WriteFile: config.ToolConfig{Enabled: true},
EditFile: config.ToolConfig{Enabled: editEnabled},
AppendFile: config.ToolConfig{Enabled: appendEnabled},
},
}
}
writeToolDesc := func(cfg *config.Config) string {
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{})
writeTool, ok := agent.Tools.Get("write_file")
if !ok {
t.Fatal("write_file tool not registered")
}
return writeTool.Description()
}
t.Run("only write_file exposed", func(t *testing.T) {
desc := writeToolDesc(newCfg(false, false))
if strings.Contains(desc, "append_file") || strings.Contains(desc, "edit_file") {
t.Fatalf("write_file must not reference unavailable tools, got: %q", desc)
}
})
t.Run("only append_file exposed", func(t *testing.T) {
desc := writeToolDesc(newCfg(false, true))
if !strings.Contains(desc, "append_file") {
t.Fatalf("expected write_file to reference append_file, got: %q", desc)
}
if strings.Contains(desc, "edit_file") {
t.Fatalf("write_file must not reference disabled edit_file, got: %q", desc)
}
})
t.Run("both exposed", func(t *testing.T) {
desc := writeToolDesc(newCfg(true, true))
if !strings.Contains(desc, "append_file") || !strings.Contains(desc, "edit_file") {
t.Fatalf("expected write_file to reference both alternatives, got: %q", desc)
}
})
}
// Availability follows the per-agent allowlist, not just the enable flag:
// editors enabled globally but hidden by frontmatter must not be named.
func TestNewAgentInstance_WriteFileCopyExcludesAllowlistHiddenAltTools(t *testing.T) {
workspace := setupWorkspace(t, map[string]string{
"AGENT.md": "---\ntools: [write_file]\n---\n# Agent\n",
})
defer cleanupWorkspace(t, workspace)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: workspace,
ModelName: "test-model",
},
},
Tools: config.ToolsConfig{
WriteFile: config.ToolConfig{Enabled: true},
EditFile: config.ToolConfig{Enabled: true},
AppendFile: config.ToolConfig{Enabled: true},
},
}
agent := NewAgentInstance(&config.AgentConfig{
ID: "restricted",
Workspace: workspace,
}, &cfg.Agents.Defaults, cfg, &mockProvider{})
if _, ok := agent.Tools.Get("edit_file"); ok {
t.Fatal("edit_file should be blocked by the allowlist")
}
if _, ok := agent.Tools.Get("append_file"); ok {
t.Fatal("append_file should be blocked by the allowlist")
}
writeTool, ok := agent.Tools.Get("write_file")
if !ok {
t.Fatal("write_file tool not registered")
}
if desc := writeTool.Description(); strings.Contains(desc, "append_file") ||
strings.Contains(desc, "edit_file") {
t.Fatalf("write_file must not name allowlist-hidden tools, got: %q", desc)
}
}
func TestNewAgentInstance_InvalidExecConfigDoesNotExit(t *testing.T) {
workspace := t.TempDir()
+10 -1
View File
@@ -2,11 +2,13 @@ package agent
import (
"context"
"fmt"
"sync"
"sync/atomic"
"github.com/sipeed/picoclaw/pkg/bus"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/logger"
)
const defaultEventSubscriberBuffer = 16
@@ -88,7 +90,14 @@ func (al *AgentLoop) UnsubscribeEvents(id uint64) {
if !ok {
return
}
sub := value.(legacyEventSubscription)
sub, ok := value.(legacyEventSubscription)
if !ok {
logger.WarnCF("agent", "UnsubscribeEvents: unexpected type in subscription map", map[string]any{
"id": id,
"type": fmt.Sprintf("%T", value),
})
return
}
sub.cancel()
if sub.sub != nil {
_ = sub.sub.Close()
+127
View File
@@ -1,11 +1,16 @@
package agent
import (
"fmt"
"regexp"
"strings"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
)
var resolvedImagePathTagRegex = regexp.MustCompile(`\[image:[^\s\]][^\]]*\]`)
func messagesContainMedia(messages []providers.Message) bool {
for _, msg := range messages {
for _, ref := range msg.Media {
@@ -65,3 +70,125 @@ func isVisionUnsupportedError(err error) bool {
return false
}
func visionUnsupportedModelError(modelName string, imageModelConfigured bool) error {
modelName = strings.TrimSpace(modelName)
if imageModelConfigured {
if modelName != "" {
return fmt.Errorf(
"selected vision model %q does not support image input; update agents.defaults.image_model to a multimodal model",
modelName,
)
}
return fmt.Errorf(
"selected vision model does not support image input; update agents.defaults.image_model to a multimodal model",
)
}
if modelName != "" {
return fmt.Errorf(
"active model %q does not support image input; configure agents.defaults.image_model with a multimodal model",
modelName,
)
}
return fmt.Errorf(
"the active model does not support image input; configure agents.defaults.image_model with a multimodal model",
)
}
func sameCandidateSet(a, b []providers.FallbackCandidate) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].StableKey() != b[i].StableKey() {
return false
}
}
return true
}
func messagesContainCurrentTurnMediaTurn(messages []providers.Message) bool {
for _, msg := range messages {
if len(msg.Media) > 0 {
return true
}
if resolvedImagePathTagRegex.MatchString(msg.Content) {
return true
}
}
return false
}
func (p *Pipeline) routeMediaTurn(ts *turnState, exec *turnExecution) error {
if p == nil || ts == nil || ts.agent == nil || exec == nil ||
!messagesContainCurrentTurnMediaTurn(currentTurnMessages(exec.callMessages, exec.currentTurnStart)) {
return nil
}
var targetCandidates []providers.FallbackCandidate
var targetModelName string
var routeReason string
switch {
case len(ts.agent.ImageCandidates) > 0:
targetCandidates = append([]providers.FallbackCandidate(nil), ts.agent.ImageCandidates...)
targetModelName = strings.TrimSpace(p.Cfg.Agents.Defaults.ImageModel)
routeReason = "configured_image_model"
case exec.usedLight && len(ts.agent.Candidates) > 0:
targetCandidates = append([]providers.FallbackCandidate(nil), ts.agent.Candidates...)
targetModelName = strings.TrimSpace(ts.agent.Model)
routeReason = "bypass_light_model_for_media"
default:
return nil
}
if len(targetCandidates) == 0 {
return nil
}
targetModel := resolvedCandidateModel(targetCandidates, targetModelName)
targetProvider := exec.activeProvider
firstCandidate := targetCandidates[0]
if provider, err := providerForFallbackCandidate(
ts.agent,
ts.agent.Provider,
targetCandidates,
firstCandidate.Provider,
firstCandidate.Model,
); err != nil {
return err
} else if provider != nil {
targetProvider = provider
}
resolvedModelName := resolvedCandidateModelName(targetCandidates, targetModelName)
if sameCandidateSet(exec.activeCandidates, targetCandidates) &&
exec.activeModel == targetModel &&
exec.llmModelName == resolvedModelName {
return nil
}
exec.activeCandidates = targetCandidates
exec.activeModel = targetModel
exec.activeProvider = targetProvider
exec.activeModelConfig = resolveActiveModelConfig(
p.Cfg,
ts.agent.Workspace,
targetCandidates,
targetModel,
p.Cfg.Agents.Defaults.Provider,
)
exec.llmModelName = resolvedModelName
exec.usedLight = false
logger.InfoCF("agent", "Media turn routing selected model", map[string]any{
"agent_id": ts.agent.ID,
"reason": routeReason,
"model": exec.activeModel,
"model_name": exec.llmModelName,
"candidates": len(exec.activeCandidates),
"messages_count": len(exec.callMessages),
})
return nil
}
+6 -14
View File
@@ -295,21 +295,16 @@ toolLoop:
contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
}
toolResultMsg := providers.Message{
Role: "tool",
Content: contentForLLM,
ToolCallID: tc.ID,
}
var toolResultMedia []string
if len(hookResult.Media) > 0 && !hookResult.ResponseHandled {
hookResult.ArtifactTags = buildArtifactTags(al.mediaStore, hookResult.Media)
contentForLLM = hookResult.ContentForLLM()
if al.cfg.Tools.IsFilterSensitiveDataEnabled() {
contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
}
toolResultMsg.Content = contentForLLM
toolResultMsg.Media = append(toolResultMsg.Media, hookResult.Media...)
toolResultMedia = append(toolResultMedia, hookResult.Media...)
}
toolResultMsg := toolResultPromptMessage(contentForLLM, tc.ID, toolResultMedia)
al.emitEvent(
runtimeevents.KindAgentToolExecEnd,
@@ -695,14 +690,11 @@ toolLoop:
contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
}
toolResultMsg := providers.Message{
Role: "tool",
Content: contentForLLM,
ToolCallID: toolCallID,
}
var toolResultMedia []string
if len(toolResult.Media) > 0 && !toolResult.ResponseHandled {
toolResultMsg.Media = append(toolResultMsg.Media, toolResult.Media...)
toolResultMedia = append(toolResultMedia, toolResult.Media...)
}
toolResultMsg := toolResultPromptMessage(contentForLLM, toolCallID, toolResultMedia)
al.emitEvent(
runtimeevents.KindAgentToolExecEnd,
ts.eventMeta("runTurn", "turn.tool.end"),
+183 -113
View File
@@ -31,7 +31,7 @@ func (p *Pipeline) CallLLM(
// PreLLM: resolve media refs (except on iteration 1 where user media is already resolved)
if iteration > 1 {
exec.messages = resolveMediaRefs(exec.messages, p.MediaStore, maxMediaSize)
exec.messages = resolveMediaRefs(exec.messages, p.MediaStore, maxMediaSize, exec.currentTurnStart)
}
// PreLLM: graceful terminal handling
@@ -64,6 +64,9 @@ func (p *Pipeline) CallLLM(
exec.providerToolDefs = nil
ts.markGracefulTerminalUsed()
}
if err := p.routeMediaTurn(ts, exec); err != nil {
return ControlBreak, err
}
exec.llmOpts = map[string]any{
"max_tokens": ts.agent.MaxTokens,
@@ -157,8 +160,8 @@ func (p *Pipeline) CallLLM(
ts.clearProviderCancel(providerCancel)
}()
al.activeRequests.Add(1)
defer al.activeRequests.Done()
al.activeRequestsInc()
defer al.activeRequestsDec()
if response, handled, streamErr := p.tryConfiguredStreamingLLM(
providerCtx,
@@ -170,36 +173,62 @@ func (p *Pipeline) CallLLM(
return response, streamErr
}
if len(exec.activeCandidates) > 1 && p.Fallback != nil {
fbResult, fbErr := p.Fallback.ExecuteCandidate(
providerCtx,
runCandidate := func(
ctx context.Context,
candidate providers.FallbackCandidate,
) (*providers.LLMResponse, error) {
candidateProvider, err := providerForFallbackCandidate(
ts.agent,
exec.activeProvider,
exec.activeCandidates,
func(ctx context.Context, candidate providers.FallbackCandidate) (*providers.LLMResponse, error) {
candidateProvider, err := providerForFallbackCandidate(
ts.agent,
exec.activeProvider,
exec.activeCandidates,
candidate.Provider,
candidate.Model,
)
if err != nil {
return nil, err
}
callOpts := shallowCloneLLMOptions(exec.llmOpts)
delete(callOpts, "thinking_level")
candidateCfg := resolveActiveModelConfig(
p.Cfg,
ts.agent.Workspace,
[]providers.FallbackCandidate{candidate},
candidate.Model,
p.Cfg.Agents.Defaults.Provider,
)
candidateThinking := thinkingSettingsFromModelConfig(candidateCfg)
applyThinkingOption(callOpts, candidateProvider, candidateThinking, true, ts.agent.ID)
exec.suppressReasoning = shouldSuppressReasoningFor(candidateThinking)
return candidateProvider.Chat(ctx, messagesForCall, toolDefsForCall, candidate.Model, callOpts)
},
candidate.Provider,
candidate.Model,
)
if err != nil {
return nil, err
}
callOpts := shallowCloneLLMOptions(exec.llmOpts)
delete(callOpts, "thinking_level")
candidateCfg := resolveActiveModelConfig(
p.Cfg,
ts.agent.Workspace,
[]providers.FallbackCandidate{candidate},
candidate.Model,
p.Cfg.Agents.Defaults.Provider,
)
candidateThinking := thinkingSettingsFromModelConfig(candidateCfg)
applyThinkingOption(callOpts, candidateProvider, candidateThinking, true, ts.agent.ID)
exec.suppressReasoning = shouldSuppressReasoningFor(candidateThinking)
return candidateProvider.Chat(ctx, messagesForCall, toolDefsForCall, candidate.Model, callOpts)
}
if len(exec.activeCandidates) > 1 && p.Fallback != nil {
var (
fbResult *providers.FallbackResult
fbErr error
)
if hasMediaRefs(messagesForCall) {
fbResult, fbErr = p.Fallback.ExecuteImage(
providerCtx,
exec.activeCandidates,
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
candidate := providers.FallbackCandidate{Provider: provider, Model: model}
for _, configured := range exec.activeCandidates {
if configured.Provider == provider && configured.Model == model {
candidate = configured
break
}
}
return runCandidate(ctx, candidate)
},
)
} else {
fbResult, fbErr = p.Fallback.ExecuteCandidate(
providerCtx,
exec.activeCandidates,
runCandidate,
)
}
if fbErr != nil {
return nil, fbErr
}
@@ -250,52 +279,16 @@ func (p *Pipeline) CallLLM(
break
}
// Retry without media if vision is unsupported
if hasMediaRefs(exec.callMessages) && isVisionUnsupportedError(err) && retry < maxRetries {
al.emitEvent(
runtimeevents.KindAgentLLMRetry,
ts.eventMeta("runTurn", "turn.llm.retry"),
LLMRetryPayload{
Attempt: retry + 1,
MaxRetries: maxRetries,
Reason: "vision_unsupported",
Error: err.Error(),
Backoff: 0,
},
if hasMediaRefs(exec.callMessages) && isVisionUnsupportedError(err) {
return ControlBreak, visionUnsupportedModelError(
exec.llmModelName,
len(ts.agent.ImageCandidates) > 0,
)
logger.WarnCF("agent", "Vision unsupported, retrying without media", map[string]any{
"error": err.Error(),
"retry": retry,
})
exec.callMessages = stripMessageMedia(exec.callMessages)
if !ts.opts.NoHistory {
exec.history = stripMessageMedia(exec.history)
ts.agent.Sessions.SetHistory(ts.sessionKey, exec.history)
for i := range ts.persistedMessages {
ts.persistedMessages[i].Media = nil
}
ts.refreshRestorePointFromSession(ts.agent)
}
continue
}
errMsg := strings.ToLower(err.Error())
isTimeoutError := errors.Is(err, context.DeadlineExceeded) ||
strings.Contains(errMsg, "deadline exceeded") ||
strings.Contains(errMsg, "client.timeout") ||
strings.Contains(errMsg, "timed out") ||
strings.Contains(errMsg, "timeout exceeded")
isNetworkError := !isTimeoutError && (strings.Contains(errMsg, "connection reset") ||
strings.Contains(errMsg, "connection refused") ||
strings.Contains(errMsg, "broken pipe") ||
strings.Contains(errMsg, "no such host") ||
strings.Contains(errMsg, "network is unreachable") ||
strings.Contains(errMsg, "read tcp") ||
strings.Contains(errMsg, "write tcp") ||
strings.Contains(errMsg, "eof"))
isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") ||
retryReason, isTransientError := transientLLMRetryReason(err)
isContextError := !isTransientError && (strings.Contains(errMsg, "context_length_exceeded") ||
strings.Contains(errMsg, "context window") ||
strings.Contains(errMsg, "context_window") ||
strings.Contains(errMsg, "maximum context length") ||
@@ -306,7 +299,7 @@ func (p *Pipeline) CallLLM(
strings.Contains(errMsg, "prompt is too long") ||
strings.Contains(errMsg, "request too large"))
if isTimeoutError && retry < maxRetries {
if isTransientError && retry < maxRetries {
backoff := time.Duration(retry+1) * time.Duration(backoffSecs) * time.Second
al.emitEvent(
runtimeevents.KindAgentLLMRetry,
@@ -314,42 +307,14 @@ func (p *Pipeline) CallLLM(
LLMRetryPayload{
Attempt: retry + 1,
MaxRetries: maxRetries,
Reason: "timeout",
Reason: retryReason,
Error: err.Error(),
Backoff: backoff,
},
)
logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{
"error": err.Error(),
"retry": retry,
"backoff": backoff.String(),
})
if sleepErr := sleepWithContext(turnCtx, backoff); sleepErr != nil {
if ts.hardAbortRequested() {
_ = ts.requestHardAbort()
return ControlBreak, nil
}
err = sleepErr
break
}
continue
}
if isNetworkError && retry < maxRetries {
backoff := time.Duration(retry+1) * time.Duration(backoffSecs) * time.Second
al.emitEvent(
runtimeevents.KindAgentLLMRetry,
ts.eventMeta("runTurn", "turn.llm.retry"),
LLMRetryPayload{
Attempt: retry + 1,
MaxRetries: maxRetries,
Reason: "network",
Error: err.Error(),
Backoff: backoff,
},
)
logger.WarnCF("agent", "Network error, retrying after backoff", map[string]any{
logger.WarnCF("agent", "Transient LLM error, retrying after backoff", map[string]any{
"error": err.Error(),
"reason": retryReason,
"retry": retry,
"backoff": backoff.String(),
})
@@ -415,14 +380,72 @@ func (p *Pipeline) CallLLM(
contextualSkills = ts.agent.ContextBuilder.ResolveActiveSkillsForContext(ts.activeSkills)
}
ts.recordSkillContextSnapshot(skillContextTriggerContextRetryRebuild, contextualSkills)
rebuildPromptReq := promptBuildRequestForTurn(ts, exec.history, exec.summary, "", nil, p.Cfg)
rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
exec.messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
exec.callMessages = exec.messages
stableHistory, protectedTurnTail := splitHistoryForActiveTurn(
exec.history,
ts.persistedMessagesSnapshot(),
)
buildMessages := func(trimmedHistory []providers.Message) []providers.Message {
fullHistory := append(append([]providers.Message(nil), trimmedHistory...), protectedTurnTail...)
rebuildPromptReq := promptBuildRequestForTurn(ts, fullHistory, exec.summary, "", nil, p.Cfg)
rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
rebuilt := ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
return resolveMediaRefs(
rebuilt,
p.MediaStore,
maxMediaSize,
len(rebuilt)-len(protectedTurnTail),
)
}
originalHistoryCount := len(exec.history)
var fit bool
var trimmedStableHistory []providers.Message
trimmedStableHistory, exec.callMessages, fit = trimHistoryToFitContextWindow(
stableHistory,
func(trimmedHistory []providers.Message) []providers.Message {
rebuilt := buildMessages(trimmedHistory)
if exec.gracefulTerminal {
return append(append([]providers.Message(nil), rebuilt...), ts.interruptHintMessage())
}
return rebuilt
},
ts.agent.ContextWindow,
exec.providerToolDefs,
ts.agent.MaxTokens,
)
exec.history = append(trimmedStableHistory, protectedTurnTail...)
exec.messages = buildMessages(trimmedStableHistory)
exec.currentTurnStart = len(exec.messages) - len(protectedTurnTail)
if exec.gracefulTerminal {
msgs := append([]providers.Message(nil), exec.messages...)
exec.callMessages = append(msgs, ts.interruptHintMessage())
}
if dropped := originalHistoryCount - len(exec.history); dropped > 0 {
logger.WarnCF("agent", "Trimmed rebuilt history after context retry compaction", map[string]any{
"session_key": ts.sessionKey,
"retry": retry,
"dropped_msgs": dropped,
"remaining_msgs": len(exec.history),
"context_window": ts.agent.ContextWindow,
"max_tokens": ts.agent.MaxTokens,
"still_overlimit": !fit,
})
} else if !fit {
logger.WarnCF("agent", "Context still exceeds budget after retry compaction rebuild", map[string]any{
"session_key": ts.sessionKey,
"retry": retry,
"history_msgs": len(exec.history),
"protected_turn_msgs": len(protectedTurnTail),
"context_window": ts.agent.ContextWindow,
"max_tokens": ts.agent.MaxTokens,
})
}
if !fit {
err = fmt.Errorf(
"context window still exceeded after retry compaction; refusing to drop active turn messages: %w",
err,
)
break
}
continue
}
break
@@ -472,11 +495,16 @@ func (p *Pipeline) CallLLM(
}
}
// Save finishReason to turnState for SubTurn truncation detection
if innerTS := turnStateFromContext(ctx); innerTS != nil {
innerTS.SetLastFinishReason(exec.response.FinishReason)
// Save finishReason and usage on the turn state. Use ts directly (the
// authoritative turn state for this call) rather than a context lookup:
// the raw ctx passed to CallLLM is not seeded with turnState (only turnCtx
// is), so turnStateFromContext(ctx) returns nil here and silently dropped
// both the finish reason and the per-turn token usage. ts is also exactly
// what the streaming publisher reads via GetLastUsage at finalize.
if ts != nil {
ts.SetLastFinishReason(exec.response.FinishReason)
if exec.response.Usage != nil {
innerTS.SetLastUsage(exec.response.Usage)
ts.SetLastUsage(exec.response.Usage)
}
}
@@ -684,3 +712,45 @@ func providerForFallbackCandidate(
}
return activeProvider, nil
}
func transientLLMRetryReason(err error) (string, bool) {
if err == nil {
return "", false
}
if failErr := providers.ClassifyError(err, "", ""); failErr != nil {
switch failErr.Reason {
case providers.FailoverTimeout:
if failErr.Status >= 500 {
return "server_error", true
}
return "timeout", true
case providers.FailoverNetwork:
return "network", true
case providers.FailoverRateLimit, providers.FailoverOverloaded:
return "rate_limit", true
}
}
errMsg := strings.ToLower(err.Error())
if errors.Is(err, context.DeadlineExceeded) ||
strings.Contains(errMsg, "deadline exceeded") ||
strings.Contains(errMsg, "client.timeout") ||
strings.Contains(errMsg, "timed out") ||
strings.Contains(errMsg, "timeout exceeded") {
return "timeout", true
}
if strings.Contains(errMsg, "connection reset") ||
strings.Contains(errMsg, "connection refused") ||
strings.Contains(errMsg, "broken pipe") ||
strings.Contains(errMsg, "no such host") ||
strings.Contains(errMsg, "network is unreachable") ||
strings.Contains(errMsg, "read tcp") ||
strings.Contains(errMsg, "write tcp") ||
strings.Contains(errMsg, "eof") {
return "network", true
}
return "", false
}
+49 -5
View File
@@ -39,8 +39,12 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
initialPromptReq := promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media, cfg)
initialPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
messages := ts.agent.ContextBuilder.BuildMessagesFromPrompt(initialPromptReq)
currentTurnStart := len(messages)
if strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0 {
currentTurnStart = len(messages) - 1
}
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize, currentTurnStart)
if !ts.opts.NoHistory {
toolDefs := filterToolsByTurnProfile(ts.agent.Tools.ToProviderDefs(), ts.profile)
@@ -66,10 +70,49 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
history = resp.History
summary = resp.Summary
}
rebuildPromptReq := promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media, cfg)
rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
originalHistoryCount := len(history)
var fit bool
history, messages, fit = trimHistoryToFitContextWindow(
history,
func(trimmedHistory []providers.Message) []providers.Message {
rebuildPromptReq := promptBuildRequestForTurn(
ts,
trimmedHistory,
summary,
ts.userMessage,
ts.media,
cfg,
)
rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
rebuilt := ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
rebuiltCurrentTurnStart := len(rebuilt)
if strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0 {
rebuiltCurrentTurnStart = len(rebuilt) - 1
}
return resolveMediaRefs(rebuilt, p.MediaStore, maxMediaSize, rebuiltCurrentTurnStart)
},
ts.agent.ContextWindow,
toolDefs,
ts.agent.MaxTokens,
)
if dropped := originalHistoryCount - len(history); dropped > 0 {
logger.WarnCF("agent", "Trimmed rebuilt history after proactive compaction", map[string]any{
"session_key": ts.sessionKey,
"dropped_msgs": dropped,
"remaining_msgs": len(history),
"context_window": ts.agent.ContextWindow,
"max_tokens": ts.agent.MaxTokens,
"still_overlimit": !fit,
})
} else if !fit {
logger.WarnCF("agent", "Context still exceeds budget "+
"after proactive compaction rebuild", map[string]any{
"session_key": ts.sessionKey,
"history_msgs": len(history),
"context_window": ts.agent.ContextWindow,
"max_tokens": ts.agent.MaxTokens,
})
}
}
}
@@ -102,6 +145,7 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
summary,
messages,
)
exec.currentTurnStart = currentTurnStart
exec.activeCandidates = activeCandidates
exec.activeModel = activeModel
exec.activeModelConfig = resolveActiveModelConfig(
+7
View File
@@ -54,6 +54,7 @@ func (p *Pipeline) tryConfiguredStreamingLLM(
channel: ts.channel,
chatID: ts.chatID,
modelName: exec.llmModelName,
ts: ts,
}
logger.DebugCF("agent", "configured streaming enabled", map[string]any{
@@ -376,6 +377,7 @@ type streamingChunkPublisher struct {
published bool
reasoningPublished bool
err error
ts *turnState
}
func (p *streamingChunkPublisher) Update(ctx context.Context, accumulated string) {
@@ -445,6 +447,11 @@ func (p *streamingChunkPublisher) Finalize(ctx context.Context, content string,
if setter, ok := p.streamer.(interface{ SetModelName(modelName string) }); ok {
setter.SetModelName(p.modelName)
}
if usage := p.ts.GetLastUsage(); usage != nil {
if setter, ok := p.streamer.(interface{ SetTurnUsage(in, out int) }); ok {
setter.SetTurnUsage(usage.PromptTokens, usage.CompletionTokens)
}
}
var err error
if streamer, ok := p.streamer.(bus.ContextUsageStreamer); ok {
err = streamer.FinalizeWithContext(ctx, content, contextUsage)
+2
View File
@@ -37,6 +37,7 @@ const (
PromptSlotMessage PromptSlot = "message"
PromptSlotSteering PromptSlot = "steering"
PromptSlotSubTurn PromptSlot = "subturn"
PromptSlotToolResult PromptSlot = "tool_result"
PromptSlotInterrupt PromptSlot = "interrupt"
PromptSlotOutput PromptSlot = "output"
)
@@ -60,6 +61,7 @@ const (
PromptSourceUserMessage PromptSourceID = "turn:user_message"
PromptSourceSteering PromptSourceID = "turn:steering"
PromptSourceSubTurnResult PromptSourceID = "turn:subturn_result"
PromptSourceToolResult PromptSourceID = "turn:tool_result"
PromptSourceInterrupt PromptSourceID = "turn:interrupt"
)
+23
View File
@@ -194,6 +194,29 @@ func userPromptMessage(content string, media []string) providers.Message {
return promptMessageWithMetadata(msg, PromptLayerTurn, PromptSlotMessage, PromptSourceUserMessage)
}
func toolResultPromptMessage(content, toolCallID string, media []string) providers.Message {
msg := providers.Message{
Role: "tool",
Content: content,
ToolCallID: toolCallID,
}
if len(media) > 0 {
msg.Media = append([]string(nil), media...)
}
return promptMessageWithMetadata(msg, PromptLayerTurn, PromptSlotToolResult, PromptSourceToolResult)
}
func toolImageFollowUpPromptMessage(media []string) providers.Message {
msg := providers.Message{
Role: "user",
Content: "[Loaded image from tool result above]",
}
if len(media) > 0 {
msg.Media = append([]string(nil), media...)
}
return promptMessageWithMetadata(msg, PromptLayerTurn, PromptSlotToolResult, PromptSourceToolResult)
}
func steeringPromptMessage(msg providers.Message) providers.Message {
return promptMessageWithDefaultMetadata(msg, PromptLayerTurn, PromptSlotSteering, PromptSourceSteering)
}
+161
View File
@@ -2,6 +2,8 @@ package agent
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
@@ -9,6 +11,7 @@ import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/providers"
)
func TestRuntimeEventLoggerFiltering(t *testing.T) {
@@ -191,6 +194,164 @@ func TestReloadProviderAndConfigRefreshesRuntimeEventLogger(t *testing.T) {
}
}
type reloadBlockingProvider struct {
chatStarted chan struct{}
releaseChat chan struct{}
closeCalled chan struct{}
}
func (p *reloadBlockingProvider) Chat(
ctx context.Context,
messages []providers.Message,
tools []providers.ToolDefinition,
model string,
options map[string]any,
) (*providers.LLMResponse, error) {
select {
case <-p.chatStarted:
default:
close(p.chatStarted)
}
select {
case <-p.releaseChat:
return &providers.LLMResponse{Content: "done"}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (p *reloadBlockingProvider) GetDefaultModel() string {
return "reload-blocking"
}
func (p *reloadBlockingProvider) Close() {
select {
case <-p.closeCalled:
default:
close(p.closeCalled)
}
}
func TestReloadProviderAndConfigWaitsForInFlightRequestsBeforeClosingOldProvider(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Agents.Defaults.Workspace = t.TempDir()
oldProvider := &reloadBlockingProvider{
chatStarted: make(chan struct{}),
releaseChat: make(chan struct{}),
closeCalled: make(chan struct{}),
}
al := NewAgentLoop(cfg, bus.NewMessageBus(), oldProvider)
defer al.Close()
msg := testInboundMessage(bus.InboundMessage{
Channel: "test",
ChatID: "reload-chat",
SenderID: "user-1",
Content: "hold request open",
})
reqDone := make(chan error, 1)
go func() {
_, err := al.processMessage(context.Background(), msg)
reqDone <- err
}()
select {
case <-oldProvider.chatStarted:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for in-flight provider request")
}
reloadDone := make(chan error, 1)
go func() {
reloaded := config.DefaultConfig()
reloaded.Agents.Defaults.Workspace = cfg.Agents.Defaults.Workspace
reloadDone <- al.ReloadProviderAndConfig(context.Background(), &mockProvider{}, reloaded)
}()
select {
case <-oldProvider.closeCalled:
t.Fatal("old provider closed before in-flight request completed")
case err := <-reloadDone:
t.Fatalf("reload returned early: %v", err)
case <-time.After(150 * time.Millisecond):
}
close(oldProvider.releaseChat)
select {
case err := <-reqDone:
if err != nil {
t.Fatalf("processMessage() error = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for in-flight request to complete")
}
select {
case err := <-reloadDone:
if err != nil {
t.Fatalf("ReloadProviderAndConfig() error = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for reload to finish")
}
select {
case <-oldProvider.closeCalled:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for old provider close")
}
}
func TestWaitForActiveRequestsHonorsContextCancellation(t *testing.T) {
al := &AgentLoop{}
al.activeReqCond = sync.NewCond(&al.activeReqMu)
al.activeRequestsInc()
defer al.activeRequestsDec()
ctx, cancel := context.WithCancel(context.Background())
cancel()
if al.waitForActiveRequests(ctx, time.Second) {
t.Fatal("waitForActiveRequests() = true, want false on canceled context")
}
}
func TestReloadProviderAndConfigReturnsCanceledErrorWhenRegistryCreationPanics(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Agents.Defaults.Workspace = t.TempDir()
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{})
defer al.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := al.ReloadProviderAndConfig(ctx, &panicProviderForReloadTest{}, cfg)
if !errors.Is(err, context.Canceled) {
t.Fatalf("ReloadProviderAndConfig() error = %v, want context canceled", err)
}
}
type panicProviderForReloadTest struct{}
func (p *panicProviderForReloadTest) Chat(
ctx context.Context,
messages []providers.Message,
tools []providers.ToolDefinition,
model string,
options map[string]any,
) (*providers.LLMResponse, error) {
return &providers.LLMResponse{Content: "unused"}, nil
}
func (p *panicProviderForReloadTest) GetDefaultModel() string {
panic("boom")
}
func TestCloseRuntimeEventLoggerSubscriptionWaitsForDrain(t *testing.T) {
eventBus := runtimeevents.NewBus()
defer func() {
+2
View File
@@ -1618,6 +1618,8 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) {
}
defaultAgent.Sessions.SetHistory(sessionKey, originalHistory)
originalHistory = defaultAgent.Sessions.GetHistory(sessionKey)
runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest(
t,
al,
+6 -2
View File
@@ -150,7 +150,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
// Inject pending steering messages
if len(pendingMessages) > 0 {
resolvedPending := resolveMediaRefs(pendingMessages, al.mediaStore, maxMediaSize)
resolvedPending := resolveMediaRefs(pendingMessages, al.mediaStore, maxMediaSize, 0)
totalContentLen := 0
for i, pm := range pendingMessages {
messages = append(messages, resolvedPending[i])
@@ -431,7 +431,11 @@ func (al *AgentLoop) askSideQuestion(
messages := agent.ContextBuilder.BuildMessagesFromPrompt(promptReq)
maxMediaSize := al.GetConfig().Agents.Defaults.GetMaxMediaSize()
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
currentTurnStart := len(messages)
if strings.TrimSpace(question) != "" || len(media) > 0 {
currentTurnStart = len(messages) - 1
}
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize, currentTurnStart)
activeCandidates, activeModel, usedLight := al.selectCandidates(agent, question, messages)
selectedModelName := sideQuestionModelName(agent, usedLight)
+85
View File
@@ -193,6 +193,38 @@ func (p *errorProvider) GetDefaultModel() string {
return "error-model"
}
type failOnceLLMProvider struct {
err error
response string
callCount int
mu sync.Mutex
}
func (p *failOnceLLMProvider) Chat(
ctx context.Context,
messages []providers.Message,
tools []providers.ToolDefinition,
model string,
opts map[string]any,
) (*providers.LLMResponse, error) {
p.mu.Lock()
p.callCount++
callCount := p.callCount
p.mu.Unlock()
if callCount == 1 {
return nil, p.err
}
return &providers.LLMResponse{
Content: p.response,
FinishReason: "stop",
}, nil
}
func (p *failOnceLLMProvider) GetDefaultModel() string {
return "fail-once-model"
}
// =============================================================================
// Test Helper Functions
// =============================================================================
@@ -586,6 +618,59 @@ func TestPipeline_CallLLM_TimeoutRetry(t *testing.T) {
}
}
func TestPipeline_CallLLM_HTTP5xxRetry(t *testing.T) {
tmpDir := t.TempDir()
provider := &failOnceLLMProvider{
err: errors.New("API request failed:\n Status: 500\n Body: internal server error"),
response: "Recovered from server error",
}
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
MaxLLMRetries: 1,
LLMRetryBackoffSecs: 1,
},
},
}
msgBus := bus.NewMessageBus()
al := NewAgentLoop(cfg, msgBus, provider)
defer al.Close()
agent := al.registry.GetDefaultAgent()
if agent == nil {
t.Fatal("expected default agent")
}
pipeline := NewPipeline(al)
ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{
turnID: "turn-1",
context: newTurnContext(nil, nil, nil),
})
exec, err := pipeline.SetupTurn(context.Background(), ts)
if err != nil {
t.Fatalf("SetupTurn failed: %v", err)
}
ctrl, err := pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1)
if err != nil {
t.Fatalf("expected HTTP 500 retry to recover, got error: %v", err)
}
if ctrl != ControlBreak {
t.Fatalf("expected ControlBreak, got %v", ctrl)
}
if exec.finalContent != "Recovered from server error" {
t.Fatalf("finalContent = %q, want recovered response", exec.finalContent)
}
if provider.callCount != 2 {
t.Fatalf("callCount = %d, want 2", provider.callCount)
}
}
func TestPipeline_CallLLM_ContextLengthError(t *testing.T) {
errorPrv := &errorProvider{errType: "context_length"}
al, agent, cleanup := newTurnCoordTestLoop(t, errorPrv)
+6 -4
View File
@@ -105,9 +105,10 @@ func TestTurnProfile_DisabledPreservesDefaultHistoryAndPrompt(t *testing.T) {
al := newTurnProfileAgentLoop(t, cfg, provider)
agent := al.GetRegistry().GetDefaultAgent()
sessionKey := "agent:default:test-default"
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
initialHistory := []providers.Message{
{Role: "user", Content: "old user"},
{Role: "assistant", Content: "old assistant"},
{Role: "user", Content: "old user", CreatedAt: &ts},
{Role: "assistant", Content: "old assistant", CreatedAt: &ts},
}
agent.Sessions.SetHistory(sessionKey, initialHistory)
agent.Sessions.SetSummary(sessionKey, "old summary")
@@ -154,9 +155,10 @@ func TestTurnProfile_HistoryOffSuppressesHistoryAndPersistence(t *testing.T) {
al := newTurnProfileAgentLoop(t, cfg, provider)
agent := al.GetRegistry().GetDefaultAgent()
sessionKey := "agent:default:test-history-off"
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
initialHistory := []providers.Message{
{Role: "user", Content: "old user"},
{Role: "assistant", Content: "old assistant"},
{Role: "user", Content: "old user", CreatedAt: &ts},
{Role: "assistant", Content: "old assistant", CreatedAt: &ts},
}
agent.Sessions.SetHistory(sessionKey, initialHistory)
agent.Sessions.SetSummary(sessionKey, "old summary")
+101 -15
View File
@@ -114,10 +114,11 @@ type ActiveTurnInfo struct {
type turnExecution struct {
// Core message state (accumulates throughout the turn)
messages []providers.Message // built from ContextBuilder, grows per-iteration
pendingMessages []providers.Message // steering/SubTurn messages awaiting injection
history []providers.Message // from ContextManager.Assemble
summary string
messages []providers.Message // built from ContextBuilder, grows per-iteration
pendingMessages []providers.Message // steering/SubTurn messages awaiting injection
history []providers.Message // from ContextManager.Assemble
summary string
currentTurnStart int
// Turn output
finalContent string
@@ -164,12 +165,13 @@ func newTurnExecution(
messages []providers.Message,
) *turnExecution {
return &turnExecution{
history: history,
summary: summary,
messages: messages,
pendingMessages: append([]providers.Message(nil), opts.InitialSteeringMessages...),
iteration: 0,
phase: LLMPhaseSetup,
history: history,
summary: summary,
messages: messages,
pendingMessages: append([]providers.Message(nil), opts.InitialSteeringMessages...),
currentTurnStart: len(messages),
iteration: 0,
phase: LLMPhaseSetup,
}
}
@@ -288,7 +290,17 @@ func (al *AgentLoop) registerActiveTurn(ts *turnState) {
}
func (al *AgentLoop) clearActiveTurn(ts *turnState) {
al.activeTurnStates.Delete(ts.sessionKey)
al.releaseSessionTurnState(ts.sessionKey, ts)
}
func (al *AgentLoop) releaseSessionTurnState(sessionKey string, expected *turnState) {
if expected == nil {
al.activeTurnStates.Delete(sessionKey)
return
}
if actual, ok := al.activeTurnStates.Load(sessionKey); ok && actual == expected {
al.activeTurnStates.Delete(sessionKey)
}
}
func (al *AgentLoop) getActiveTurnState(sessionKey string) *turnState {
@@ -643,13 +655,17 @@ func (ts *turnState) recordPersistedMessage(msg providers.Message) {
ts.persistedMessages = append(ts.persistedMessages, msg)
}
func (ts *turnState) persistedMessagesSnapshot() []providers.Message {
ts.mu.RLock()
defer ts.mu.RUnlock()
return append([]providers.Message(nil), ts.persistedMessages...)
}
func (ts *turnState) refreshRestorePointFromSession(agent *AgentInstance) {
history := agent.Sessions.GetHistory(ts.sessionKey)
summary := agent.Sessions.GetSummary(ts.sessionKey)
ts.mu.RLock()
persisted := append([]providers.Message(nil), ts.persistedMessages...)
ts.mu.RUnlock()
persisted := ts.persistedMessagesSnapshot()
if matched := matchingTurnMessageTail(history, persisted); matched > 0 {
history = append([]providers.Message(nil), history[:len(history)-matched]...)
@@ -689,13 +705,83 @@ func (ts *turnState) restoreSession(agent *AgentInstance) error {
func matchingTurnMessageTail(history, persisted []providers.Message) int {
maxMatch := min(len(history), len(persisted))
for size := maxMatch; size > 0; size-- {
if reflect.DeepEqual(history[len(history)-size:], persisted[len(persisted)-size:]) {
if messageSlicesEquivalent(history[len(history)-size:], persisted[len(persisted)-size:]) {
return size
}
}
return 0
}
func splitHistoryForActiveTurn(
history []providers.Message,
persisted []providers.Message,
) ([]providers.Message, []providers.Message) {
matched := matchingTurnMessageTail(history, persisted)
if matched <= 0 {
return append([]providers.Message(nil), history...), nil
}
stable := append([]providers.Message(nil), history[:len(history)-matched]...)
protected := append([]providers.Message(nil), history[len(history)-matched:]...)
return stable, protected
}
func messageSlicesEquivalent(a, b []providers.Message) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if !messagesEquivalent(a[i], b[i]) {
return false
}
}
return true
}
func messagesEquivalent(a, b providers.Message) bool {
return reflect.DeepEqual(normalizeMessageForComparison(a), normalizeMessageForComparison(b))
}
func normalizeMessageForComparison(msg providers.Message) providers.Message {
msg.PromptLayer = ""
msg.PromptSlot = ""
msg.PromptSource = ""
if len(msg.Media) == 0 {
msg.Media = nil
}
if len(msg.Attachments) == 0 {
msg.Attachments = nil
}
if len(msg.SystemParts) == 0 {
msg.SystemParts = nil
} else {
msg.SystemParts = append([]providers.ContentBlock(nil), msg.SystemParts...)
for i := range msg.SystemParts {
msg.SystemParts[i].PromptLayer = ""
msg.SystemParts[i].PromptSlot = ""
msg.SystemParts[i].PromptSource = ""
}
}
if len(msg.ToolCalls) == 0 {
msg.ToolCalls = nil
} else {
msg.ToolCalls = append([]providers.ToolCall(nil), msg.ToolCalls...)
for i := range msg.ToolCalls {
msg.ToolCalls[i].Name = ""
msg.ToolCalls[i].Arguments = nil
msg.ToolCalls[i].ThoughtSignature = ""
if msg.ToolCalls[i].Function != nil {
fn := *msg.ToolCalls[i].Function
fn.ThoughtSignature = ""
msg.ToolCalls[i].Function = &fn
}
}
}
return msg
}
func (ts *turnState) interruptHintMessage() providers.Message {
_, hint := ts.gracefulInterruptRequested()
content := "Interrupt requested. Stop scheduling tools and provide a short final summary."
+112
View File
@@ -0,0 +1,112 @@
package agent
import (
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/providers"
)
func TestMatchingTurnMessageTail_IgnoresInternalRuntimeFields(t *testing.T) {
history := []providers.Message{
{Role: "user", Content: "question"},
{
Role: "assistant",
ToolCalls: []providers.ToolCall{
{
ID: "call_1",
Type: "function",
Function: &providers.FunctionCall{
Name: "read_file",
Arguments: `{"path":"/tmp/test"}`,
},
},
},
},
}
persisted := []providers.Message{
userPromptMessage("question", nil),
{
Role: "assistant",
ToolCalls: []providers.ToolCall{
{
ID: "call_1",
Type: "function",
Name: "read_file",
Arguments: map[string]any{"path": "/tmp/test"},
ThoughtSignature: "internal-signature",
Function: &providers.FunctionCall{
Name: "read_file",
Arguments: `{"path":"/tmp/test"}`,
ThoughtSignature: "internal-signature",
},
},
},
},
}
if got := matchingTurnMessageTail(history, persisted); got != 2 {
t.Fatalf("matchingTurnMessageTail() = %d, want 2", got)
}
}
func TestSplitHistoryForActiveTurn_ProtectsPersistedTail(t *testing.T) {
history := []providers.Message{
{Role: "user", Content: "old question"},
{Role: "assistant", Content: "old answer"},
{Role: "user", Content: "current question"},
{Role: "tool", Content: "tool output", ToolCallID: "call_1"},
}
persisted := []providers.Message{
userPromptMessage("current question", nil),
{Role: "tool", Content: "tool output", ToolCallID: "call_1"},
}
stable, protected := splitHistoryForActiveTurn(history, persisted)
if len(stable) != 2 {
t.Fatalf("stable history len = %d, want 2", len(stable))
}
if len(protected) != 2 {
t.Fatalf("protected tail len = %d, want 2", len(protected))
}
if protected[0].Content != "current question" {
t.Fatalf("protected[0].Content = %q, want current question", protected[0].Content)
}
}
func TestTrimHistoryToFitContextWindow_WithProtectedTurnTailKeepsActiveTurn(t *testing.T) {
current := strings.Repeat("current turn ", 80)
history := []providers.Message{
{Role: "user", Content: strings.Repeat("old turn ", 60)},
{Role: "assistant", Content: strings.Repeat("old reply ", 60)},
{Role: "user", Content: current},
}
stable, protected := splitHistoryForActiveTurn(history, []providers.Message{
userPromptMessage(current, nil),
})
trimmedStable, messages, fit := trimHistoryToFitContextWindow(
stable,
func(trimmedHistory []providers.Message) []providers.Message {
return append(append([]providers.Message(nil), trimmedHistory...), protected...)
},
120,
nil,
0,
)
if fit {
t.Fatal("expected protected active turn alone to remain over budget")
}
if len(trimmedStable) != 0 {
t.Fatalf("trimmed stable history len = %d, want 0", len(trimmedStable))
}
if len(messages) != 1 {
t.Fatalf("messages len = %d, want 1 protected active-turn message", len(messages))
}
if messages[0].Content != current {
t.Fatalf("messages[0].Content = %q, want protected current turn", messages[0].Content)
}
}
+45 -2
View File
@@ -25,6 +25,8 @@ Instead:
- `voice.tts_model_name` selects a named entry from `model_list`.
- That `model_list` entry provides the provider, model ID, API base, and proxy settings.
- For providers that need model-specific TTS parameters, use `model_list[].extra_body`
to pass fields such as `voice` and `response_format`.
- `.security.yml` stores the API key for the same named model entry.
This is the recommended and supported configuration pattern.
@@ -87,6 +89,43 @@ model_list:
If you use a custom MiMo endpoint, you can also set `api_base` explicitly. Otherwise PicoClaw will use the provider default.
### Option C: OpenRouter MAI Voice 2
Some OpenAI-compatible TTS routes require provider-specific request fields.
OpenRouter's `microsoft/mai-voice-2` is one example: it needs a model-specific
voice name and works best with `response_format: "mp3"`.
`config.json`
```json
{
"voice": {
"tts_model_name": "mai-voice-2"
},
"model_list": [
{
"model_name": "mai-voice-2",
"provider": "openrouter",
"model": "microsoft/mai-voice-2",
"api_base": "https://openrouter.ai/api/v1",
"extra_body": {
"voice": "en-US-Harper:MAI-Voice-2",
"response_format": "mp3"
}
}
]
}
```
`.security.yml`
```yaml
model_list:
mai-voice-2:
api_keys:
- "sk-or-your-openrouter-key"
```
## What PicoClaw Sends Today
The current TTS runtime uses an OpenAI-compatible speech request with these defaults:
@@ -96,11 +135,14 @@ The current TTS runtime uses an OpenAI-compatible speech request with these defa
- Voice: `alloy`
- Model: taken from the selected `model_list` entry
These defaults can now be overridden per model through `model_list[].extra_body`.
That means:
- `openai/tts-1` works naturally.
- Other OpenAI-compatible providers can work if they accept the same request format.
- PicoClaw currently does not expose a user-facing config field for changing the TTS voice from `alloy`.
- Provider-specific TTS models may need their own `voice` and `response_format` values.
- If a provider rejects `response_format`, PicoClaw retries once without that field.
## How PicoClaw Chooses a TTS Provider
@@ -124,7 +166,8 @@ PicoClaw normalizes the configured base URL for TTS:
- Setting `voice.tts_model_name` to a name that does not exist in `model_list`.
- Adding a TTS model but forgetting to put its API key in `.security.yml`.
- Assuming PicoClaw will automatically use provider-specific custom voices.
- Assuming PicoClaw will automatically infer provider-specific custom voices.
- Forgetting to set `model_list[].extra_body.voice` or `model_list[].extra_body.response_format` for TTS models that require them.
- Using a provider endpoint that is not compatible with the OpenAI `/audio/speech` request format.
## Minimal Checklist
+119 -16
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -16,14 +17,49 @@ import (
)
type OpenAITTSProvider struct {
apiKey string
apiBase string
voice string
model string
httpClient *http.Client
apiKey string
apiBase string
voice string
model string
responseFormat string
httpClient *http.Client
}
type OpenAITTSOptions struct {
Voice string
ResponseFormat string
}
type openAITTSAudioStream struct {
io.ReadCloser
fileExt string
contentType string
}
func (s *openAITTSAudioStream) AudioFileMeta() (string, string) {
return s.fileExt, s.contentType
}
type openAITTSAPIError struct {
statusCode int
body string
}
func (e *openAITTSAPIError) Error() string {
return fmt.Sprintf("API error (status %d): %s", e.statusCode, e.body)
}
func NewOpenAITTSProvider(apiKey string, apiBase string, proxyURL string, model string) *OpenAITTSProvider {
return NewOpenAITTSProviderWithOptions(apiKey, apiBase, proxyURL, model, OpenAITTSOptions{})
}
func NewOpenAITTSProviderWithOptions(
apiKey string,
apiBase string,
proxyURL string,
model string,
options OpenAITTSOptions,
) *OpenAITTSProvider {
// Normalize apiBase to avoid malformed endpoints like
// "https://api.openai.com/audio/speech" when "/v1" is required.
if apiBase == "" {
@@ -75,12 +111,23 @@ func NewOpenAITTSProvider(apiKey string, apiBase string, proxyURL string, model
model = "tts-1"
}
voice := strings.TrimSpace(options.Voice)
if voice == "" {
voice = "alloy"
}
responseFormat := strings.TrimSpace(options.ResponseFormat)
if responseFormat == "" {
responseFormat = "opus"
}
return &OpenAITTSProvider{
apiKey: apiKey,
apiBase: apiBase,
voice: "alloy",
model: model,
httpClient: client,
apiKey: apiKey,
apiBase: apiBase,
voice: voice,
model: model,
responseFormat: responseFormat,
httpClient: client,
}
}
@@ -91,11 +138,42 @@ func (t *OpenAITTSProvider) Name() string {
func (t *OpenAITTSProvider) Synthesize(ctx context.Context, text string) (io.ReadCloser, error) {
logger.DebugCF("voice-tts", "Starting TTS synthesis", map[string]any{"text_len": len(text)})
responseFormat := t.responseFormat
stream, err := t.doSpeechRequest(ctx, text, responseFormat)
if err != nil {
var apiErr *openAITTSAPIError
if errors.As(err, &apiErr) && shouldRetryWithoutResponseFormat(apiErr.body) {
logger.InfoCF("voice-tts", "Retrying TTS without response_format after provider rejection", map[string]any{
"model": t.model,
})
responseFormat = ""
stream, err = t.doSpeechRequest(ctx, text, responseFormat)
}
if err != nil {
return nil, err
}
}
fileExt, contentType := audioFileMetaForResponseFormat(responseFormat)
return &openAITTSAudioStream{
ReadCloser: stream,
fileExt: fileExt,
contentType: contentType,
}, nil
}
func (t *OpenAITTSProvider) doSpeechRequest(
ctx context.Context,
text string,
responseFormat string,
) (io.ReadCloser, error) {
reqBody := map[string]any{
"model": t.model,
"input": text,
"voice": t.voice,
"response_format": "opus",
"model": t.model,
"input": text,
"voice": t.voice,
}
if responseFormat != "" {
reqBody["response_format"] = responseFormat
}
jsonData, err := json.Marshal(reqBody)
@@ -118,9 +196,34 @@ func (t *OpenAITTSProvider) Synthesize(ctx context.Context, text string) (io.Rea
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
body = []byte(fmt.Sprintf("(failed to read error body: %v)", readErr))
}
return nil, &openAITTSAPIError{
statusCode: resp.StatusCode,
body: string(body),
}
}
return resp.Body, nil
}
func shouldRetryWithoutResponseFormat(body string) bool {
lower := strings.ToLower(body)
if !strings.Contains(lower, "response_format") {
return false
}
return strings.Contains(lower, "invalid") || strings.Contains(lower, "unsupported")
}
func audioFileMetaForResponseFormat(responseFormat string) (string, string) {
switch strings.ToLower(strings.TrimSpace(responseFormat)) {
case "", "mp3":
return ".mp3", "audio/mpeg"
case "wav":
return ".wav", "audio/wav"
default:
return ".ogg", "audio/ogg"
}
}
+33 -2
View File
@@ -19,6 +19,10 @@ type TTSProvider interface {
Synthesize(ctx context.Context, text string) (io.ReadCloser, error)
}
type ttsAudioMetaProvider interface {
AudioFileMeta() (fileExt string, contentType string)
}
func providerFromModelConfig(mc *config.ModelConfig) TTSProvider {
if mc == nil || mc.APIKey() == "" {
return nil
@@ -33,10 +37,31 @@ func providerFromModelConfig(mc *config.ModelConfig) TTSProvider {
case "mimo":
return NewMimoTTSProvider(mc.APIKey(), providers.ResolveAPIBase(mc), modelID, mc.Proxy)
default:
return NewOpenAITTSProvider(mc.APIKey(), providers.ResolveAPIBase(mc), mc.Proxy, modelID)
return NewOpenAITTSProviderWithOptions(
mc.APIKey(),
providers.ResolveAPIBase(mc),
mc.Proxy,
modelID,
openAITTSOptionsFromModelConfig(mc),
)
}
}
func openAITTSOptionsFromModelConfig(mc *config.ModelConfig) OpenAITTSOptions {
options := OpenAITTSOptions{}
if mc == nil || mc.ExtraBody == nil {
return options
}
if voice, ok := mc.ExtraBody["voice"].(string); ok {
options.Voice = strings.TrimSpace(voice)
}
if responseFormat, ok := mc.ExtraBody["response_format"].(string); ok {
options.ResponseFormat = strings.TrimSpace(responseFormat)
}
return options
}
func DetectTTS(cfg *config.Config) TTSProvider {
if cfg == nil {
return nil
@@ -100,6 +125,12 @@ func SynthesizeAndStore(
fileExt = ".mp3"
contentType = "audio/mpeg"
}
if metaProvider, ok := stream.(ttsAudioMetaProvider); ok {
if ext, ct := metaProvider.AudioFileMeta(); ext != "" && ct != "" {
fileExt = ext
contentType = ct
}
}
file, err := os.CreateTemp(media.TempDir(), "tts-*"+fileExt)
if err != nil {
@@ -115,7 +146,7 @@ func SynthesizeAndStore(
_, err = io.Copy(file, stream)
if err != nil {
file.Close()
_ = file.Close()
return "", fmt.Errorf("failed to write tts audio: %w", err)
}
+157
View File
@@ -132,6 +132,64 @@ func TestOpenAITTSProvider_SynthesizeNon200(t *testing.T) {
}
}
func TestOpenAITTSProvider_SynthesizeRetriesWithoutResponseFormat(t *testing.T) {
var requestBodies []map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
bodyBytes, _ := io.ReadAll(r.Body)
_ = r.Body.Close()
var body map[string]any
_ = json.Unmarshal(bodyBytes, &body)
requestBodies = append(requestBodies, body)
if len(requestBodies) == 1 {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"response_format is invalid"}`))
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("audio-bytes"))
}))
defer server.Close()
provider := NewOpenAITTSProvider("k123", server.URL, "", "x-ai/grok-voice-tts-1.0")
stream, err := provider.Synthesize(context.Background(), "hello")
if err != nil {
t.Fatalf("Synthesize failed: %v", err)
}
defer stream.Close()
data, err := io.ReadAll(stream)
if err != nil {
t.Fatalf("read stream failed: %v", err)
}
if string(data) != "audio-bytes" {
t.Fatalf("response body mismatch: got %q", string(data))
}
if len(requestBodies) != 2 {
t.Fatalf("request count mismatch: got %d, want 2", len(requestBodies))
}
if requestBodies[0]["response_format"] != "opus" {
t.Fatalf("first request should include opus response_format, got %#v", requestBodies[0]["response_format"])
}
if _, ok := requestBodies[1]["response_format"]; ok {
t.Fatalf("second request should omit response_format, got %#v", requestBodies[1]["response_format"])
}
metaStream, ok := stream.(interface {
AudioFileMeta() (string, string)
})
if !ok {
t.Fatal("stream does not expose audio metadata")
}
fileExt, contentType := metaStream.AudioFileMeta()
if fileExt != ".mp3" || contentType != "audio/mpeg" {
t.Fatalf("audio metadata mismatch: got (%q, %q)", fileExt, contentType)
}
}
func TestNewOpenAITTSProvider_UsesConfiguredModel(t *testing.T) {
t.Parallel()
@@ -144,6 +202,27 @@ func TestNewOpenAITTSProvider_UsesConfiguredModel(t *testing.T) {
}
}
func TestNewOpenAITTSProvider_UsesConfiguredVoiceAndResponseFormat(t *testing.T) {
t.Parallel()
provider := NewOpenAITTSProviderWithOptions(
"key",
"https://openrouter.ai/api/v1",
"",
"microsoft/mai-voice-2",
OpenAITTSOptions{
Voice: "en-US-Harper:MAI-Voice-2",
ResponseFormat: "mp3",
},
)
if provider.voice != "en-US-Harper:MAI-Voice-2" {
t.Fatalf("voice mismatch: got %q", provider.voice)
}
if provider.responseFormat != "mp3" {
t.Fatalf("responseFormat mismatch: got %q", provider.responseFormat)
}
}
func TestDetectTTS_UsesMimoProviderForMimoModels(t *testing.T) {
t.Parallel()
@@ -170,6 +249,36 @@ func TestDetectTTS_UsesMimoProviderForMimoModels(t *testing.T) {
}
}
func TestDetectTTS_UsesOpenAIExtraBodyVoiceAndResponseFormat(t *testing.T) {
t.Parallel()
provider := DetectTTS(&config.Config{
Voice: config.VoiceConfig{TTSModelName: "mai-voice"},
ModelList: []*config.ModelConfig{
{
ModelName: "mai-voice",
Model: "openrouter/microsoft/mai-voice-2",
APIKeys: config.SimpleSecureStrings("sk-openrouter"),
ExtraBody: map[string]any{
"voice": "en-US-Harper:MAI-Voice-2",
"response_format": "mp3",
},
},
},
})
ttsProvider, ok := provider.(*OpenAITTSProvider)
if !ok {
t.Fatalf("DetectTTS() type = %T, want *OpenAITTSProvider", provider)
}
if ttsProvider.voice != "en-US-Harper:MAI-Voice-2" {
t.Fatalf("voice mismatch: got %q", ttsProvider.voice)
}
if ttsProvider.responseFormat != "mp3" {
t.Fatalf("responseFormat mismatch: got %q", ttsProvider.responseFormat)
}
}
type stubTTSProvider struct {
name string
}
@@ -245,3 +354,51 @@ func TestSynthesizeAndStore_UsesMp3MetadataForMimo(t *testing.T) {
t.Fatalf("filename extension = %q, want %q", filepath.Ext(meta.Filename), ".mp3")
}
}
func TestSynthesizeAndStore_UsesStreamProvidedAudioMetadata(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
bodyBytes, _ := io.ReadAll(r.Body)
_ = r.Body.Close()
var body map[string]any
_ = json.Unmarshal(bodyBytes, &body)
if body["response_format"] == "opus" {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"response_format is invalid"}`))
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("mp3-audio"))
}))
defer server.Close()
store := media.NewFileMediaStore()
provider := NewOpenAITTSProvider("k123", server.URL, "", "x-ai/grok-voice-tts-1.0")
ref, err := SynthesizeAndStore(
context.Background(),
provider,
store,
"hello",
"",
"telegram",
"chat123",
)
if err != nil {
t.Fatalf("SynthesizeAndStore failed: %v", err)
}
path, meta, err := store.ResolveWithMeta(ref)
if err != nil {
t.Fatalf("ResolveWithMeta failed: %v", err)
}
if meta.ContentType != "audio/mpeg" {
t.Fatalf("ContentType = %q, want %q", meta.ContentType, "audio/mpeg")
}
if filepath.Ext(path) != ".mp3" {
t.Fatalf("stored file extension = %q, want %q", filepath.Ext(path), ".mp3")
}
if filepath.Ext(meta.Filename) != ".mp3" {
t.Fatalf("filename extension = %q, want %q", filepath.Ext(meta.Filename), ".mp3")
}
}
+207 -18
View File
@@ -3,8 +3,10 @@ package bus
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"time"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/logger"
@@ -13,6 +15,10 @@ import (
// ErrBusClosed is returned when publishing to a closed MessageBus.
var ErrBusClosed = errors.New("message bus closed")
// ErrBusBackpressure is returned when a publish attempt exceeds the configured
// backpressure wait budget and the message is dropped.
var ErrBusBackpressure = errors.New("message bus backpressure")
var (
ErrMissingInboundContext = errors.New("inbound message context is required")
ErrMissingOutboundContext = errors.New("outbound message context is required")
@@ -21,6 +27,41 @@ var (
const defaultBusBufferSize = 64
const (
defaultAudioPublishTimeout = 150 * time.Millisecond
)
type publishPolicy struct {
stream string
// timeout is the backpressure drop budget. When positive, a full channel
// causes the message to be dropped after this duration. When zero, the
// publish blocks until context cancellation or bus close (no drop).
timeout time.Duration
}
type streamStats struct {
dropped atomic.Uint64
lastDropped atomic.Int64
lastWaitNanos atomic.Int64
}
type MessageBusStats struct {
Inbound StreamStats `json:"inbound"`
Outbound StreamStats `json:"outbound"`
OutboundMedia StreamStats `json:"outbound_media"`
AudioChunks StreamStats `json:"audio_chunks"`
VoiceControls StreamStats `json:"voice_controls"`
}
type StreamStats struct {
Depth int `json:"depth"`
Capacity int `json:"capacity"`
DroppedTotal uint64 `json:"dropped_total"`
LastDroppedAt time.Time `json:"last_dropped_at,omitempty"`
LastDropWait string `json:"last_drop_wait,omitempty"`
LastDropWaitMillis int64 `json:"last_drop_wait_ms,omitempty"`
}
// StreamDelegate is implemented by the channel Manager to provide streaming
// capabilities to the agent loop without tight coupling.
type StreamDelegate interface {
@@ -62,8 +103,14 @@ type MessageBus struct {
done chan struct{}
closed atomic.Bool
wg sync.WaitGroup
publishMu sync.Mutex
streamDelegate atomic.Value // stores StreamDelegate
eventPublisher atomic.Value // stores EventPublisher
inboundStats streamStats
outboundStats streamStats
mediaStats streamStats
audioStats streamStats
voiceStats streamStats
}
// EventPublisher is the minimal runtime event publisher used by MessageBus.
@@ -83,13 +130,13 @@ func NewMessageBus() *MessageBus {
}
}
func publish[T any](ctx context.Context, mb *MessageBus, ch chan T, msg T) error {
// check bus closed before acquiring wg, to avoid unnecessary wg.Add and potential deadlock
func (mb *MessageBus) enterPublish(ctx context.Context) error {
mb.publishMu.Lock()
defer mb.publishMu.Unlock()
if mb.closed.Load() {
return ErrBusClosed
}
// check again,before sending message, to avoid sending to closed channel
select {
case <-ctx.Done():
return ctx.Err()
@@ -99,8 +146,63 @@ func publish[T any](ctx context.Context, mb *MessageBus, ch chan T, msg T) error
}
mb.wg.Add(1)
return nil
}
func publish[T any](
ctx context.Context,
mb *MessageBus,
ch chan T,
msg T,
policy publishPolicy,
stats *streamStats,
scope runtimeevents.Scope,
) error {
if ctx == nil {
ctx = context.Background()
}
if err := mb.enterPublish(ctx); err != nil {
return err
}
defer mb.wg.Done()
// timeout == 0 means no backpressure drop budget; block until context
// cancellation or bus close. This is the default for critical streams
// (inbound, outbound, outboundMedia, voiceControl) where dropping
// messages silently is undesirable.
if policy.timeout > 0 {
timer := time.NewTimer(policy.timeout)
defer timer.Stop()
select {
case ch <- msg:
return nil
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
droppedTotal := stats.dropped.Add(1)
now := time.Now()
stats.lastDropped.Store(now.UnixNano())
stats.lastWaitNanos.Store(policy.timeout.Nanoseconds())
queueDepth := len(ch)
queueCap := cap(ch)
mb.publishDrop(
policy.stream, scope, "queue_full_timeout",
policy.timeout, queueDepth, queueCap, droppedTotal,
)
logger.WarnCF("bus", "Dropped bus message due to backpressure", map[string]any{
"stream": policy.stream,
"wait_ms": policy.timeout.Milliseconds(),
"queue_depth": queueDepth,
"queue_capacity": queueCap,
"dropped_total": droppedTotal,
})
return fmt.Errorf("%w: %s queue full after %s", ErrBusBackpressure, policy.stream, policy.timeout)
case <-mb.done:
return ErrBusClosed
}
}
select {
case ch <- msg:
return nil
@@ -117,8 +219,13 @@ func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) er
mb.publishFailure("inbound", runtimeScopeFromInboundContext(msg.Context), ErrMissingInboundContext)
return ErrMissingInboundContext
}
if err := publish(ctx, mb, mb.inbound, msg); err != nil {
mb.publishFailure("inbound", runtimeScopeFromInboundContext(msg.Context), err)
if err := publish(ctx, mb, mb.inbound, msg, publishPolicy{
stream: "inbound",
}, &mb.inboundStats, runtimeScopeFromInboundContext(msg.Context)); err != nil {
scope := runtimeScopeFromInboundContext(msg.Context)
if !errors.Is(err, ErrBusBackpressure) {
mb.publishFailure("inbound", scope, err)
}
return err
}
return nil
@@ -134,8 +241,13 @@ func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage)
mb.publishFailure("outbound", runtimeScopeFromInboundContext(msg.Context), ErrMissingOutboundContext)
return ErrMissingOutboundContext
}
if err := publish(ctx, mb, mb.outbound, msg); err != nil {
mb.publishFailure("outbound", runtimeScopeFromInboundContext(msg.Context), err)
if err := publish(ctx, mb, mb.outbound, msg, publishPolicy{
stream: "outbound",
}, &mb.outboundStats, runtimeScopeFromInboundContext(msg.Context)); err != nil {
scope := runtimeScopeFromInboundContext(msg.Context)
if !errors.Is(err, ErrBusBackpressure) {
mb.publishFailure("outbound", scope, err)
}
return err
}
return nil
@@ -151,8 +263,13 @@ func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMedi
mb.publishFailure("outbound_media", runtimeScopeFromInboundContext(msg.Context), ErrMissingOutboundMediaContext)
return ErrMissingOutboundMediaContext
}
if err := publish(ctx, mb, mb.outboundMedia, msg); err != nil {
mb.publishFailure("outbound_media", runtimeScopeFromInboundContext(msg.Context), err)
if err := publish(ctx, mb, mb.outboundMedia, msg, publishPolicy{
stream: "outbound_media",
}, &mb.mediaStats, runtimeScopeFromInboundContext(msg.Context)); err != nil {
scope := runtimeScopeFromInboundContext(msg.Context)
if !errors.Is(err, ErrBusBackpressure) {
mb.publishFailure("outbound_media", scope, err)
}
return err
}
return nil
@@ -163,8 +280,14 @@ func (mb *MessageBus) OutboundMediaChan() <-chan OutboundMediaMessage {
}
func (mb *MessageBus) PublishAudioChunk(ctx context.Context, chunk AudioChunk) error {
if err := publish(ctx, mb, mb.audioChunks, chunk); err != nil {
mb.publishFailure("audio_chunk", runtimeScopeFromAudioChunk(chunk), err)
if err := publish(ctx, mb, mb.audioChunks, chunk, publishPolicy{
stream: "audio_chunk",
timeout: defaultAudioPublishTimeout,
}, &mb.audioStats, runtimeScopeFromAudioChunk(chunk)); err != nil {
scope := runtimeScopeFromAudioChunk(chunk)
if !errors.Is(err, ErrBusBackpressure) {
mb.publishFailure("audio_chunk", scope, err)
}
return err
}
return nil
@@ -175,8 +298,13 @@ func (mb *MessageBus) AudioChunksChan() <-chan AudioChunk {
}
func (mb *MessageBus) PublishVoiceControl(ctx context.Context, ctrl VoiceControl) error {
if err := publish(ctx, mb, mb.voiceControls, ctrl); err != nil {
mb.publishFailure("voice_control", runtimeScopeFromVoiceControl(ctrl), err)
if err := publish(ctx, mb, mb.voiceControls, ctrl, publishPolicy{
stream: "voice_control",
}, &mb.voiceStats, runtimeScopeFromVoiceControl(ctrl)); err != nil {
scope := runtimeScopeFromVoiceControl(ctrl)
if !errors.Is(err, ErrBusBackpressure) {
mb.publishFailure("voice_control", scope, err)
}
return err
}
return nil
@@ -204,15 +332,76 @@ func (mb *MessageBus) GetStreamer(ctx context.Context, channel, chatID, sessionK
return nil, false
}
func (mb *MessageBus) Stats() MessageBusStats {
if mb == nil {
return MessageBusStats{}
}
return MessageBusStats{
Inbound: snapshotStreamStats(mb.inbound, &mb.inboundStats),
Outbound: snapshotStreamStats(mb.outbound, &mb.outboundStats),
OutboundMedia: snapshotStreamStats(mb.outboundMedia, &mb.mediaStats),
AudioChunks: snapshotStreamStats(mb.audioChunks, &mb.audioStats),
VoiceControls: snapshotStreamStats(mb.voiceControls, &mb.voiceStats),
}
}
// HealthCheck returns a snapshot of queue depths and cumulative drop counts
// across all streams. It always reports ok=true: backpressure-induced drops are
// reflected in the message string for telemetry but do not affect the boolean.
// Callers that need readiness semantics (e.g. returning 503 when drops occur)
// should inspect Stats() directly and apply their own threshold logic.
func (mb *MessageBus) HealthCheck() (bool, string) {
stats := mb.Stats()
totalDropped := stats.Inbound.DroppedTotal +
stats.Outbound.DroppedTotal +
stats.OutboundMedia.DroppedTotal +
stats.AudioChunks.DroppedTotal +
stats.VoiceControls.DroppedTotal
message := fmt.Sprintf(
"in=%d/%d out=%d/%d media=%d/%d audio=%d/%d voice=%d/%d dropped=%d",
stats.Inbound.Depth,
stats.Inbound.Capacity,
stats.Outbound.Depth,
stats.Outbound.Capacity,
stats.OutboundMedia.Depth,
stats.OutboundMedia.Capacity,
stats.AudioChunks.Depth,
stats.AudioChunks.Capacity,
stats.VoiceControls.Depth,
stats.VoiceControls.Capacity,
totalDropped,
)
return true, message
}
func snapshotStreamStats[T any](ch chan T, stats *streamStats) StreamStats {
snapshot := StreamStats{
DroppedTotal: stats.dropped.Load(),
}
if ch != nil {
snapshot.Depth = len(ch)
snapshot.Capacity = cap(ch)
}
if unixNano := stats.lastDropped.Load(); unixNano > 0 {
snapshot.LastDroppedAt = time.Unix(0, unixNano)
}
if waitNanos := stats.lastWaitNanos.Load(); waitNanos > 0 {
wait := time.Duration(waitNanos)
snapshot.LastDropWait = wait.String()
snapshot.LastDropWaitMillis = wait.Milliseconds()
}
return snapshot
}
func (mb *MessageBus) Close() {
mb.closeOnce.Do(func() {
mb.publishCloseEvent(runtimeevents.KindBusCloseStarted, 0)
// notify all blocked publishers to exit
close(mb.done)
// because every publisher will check mb.closed before acquiring wg
// so we can be sure that new publishers will not be added new messages after this point
mb.publishMu.Lock()
mb.closed.Store(true)
close(mb.done)
mb.publishMu.Unlock()
// wait for all ongoing Publish calls to finish, ensuring all messages have been sent to channels or exited
mb.wg.Wait()
+147
View File
@@ -2,6 +2,7 @@ package bus
import (
"context"
"errors"
"sync"
"testing"
"time"
@@ -459,6 +460,84 @@ func TestPublishAudioChunkSubscribe(t *testing.T) {
}
}
func TestPublishAudioChunk_BackpressureDropPublishesRuntimeEvent(t *testing.T) {
eventBus := runtimeevents.NewBus()
defer func() {
if err := eventBus.Close(); err != nil {
t.Errorf("event bus close failed: %v", err)
}
}()
_, eventsCh, err := eventBus.Channel().OfKind(runtimeevents.KindBusMessageDropped).SubscribeChan(
t.Context(),
runtimeevents.SubscribeOptions{Name: "bus-drop-events", Buffer: 1},
)
if err != nil {
t.Fatalf("SubscribeChan failed: %v", err)
}
mb := NewMessageBus()
defer mb.Close()
mb.SetEventPublisher(eventBus)
for i := range defaultBusBufferSize * 4 {
if pubErr := mb.PublishAudioChunk(context.Background(), AudioChunk{
SessionID: "voice-1",
SpeakerID: "speaker-1",
ChatID: "chat-1",
Channel: "discord",
Sequence: uint64(i),
Format: "opus",
Data: []byte{0x01},
}); pubErr != nil {
t.Fatalf("fill failed at %d: %v", i, pubErr)
}
}
err = mb.PublishAudioChunk(context.Background(), AudioChunk{
SessionID: "voice-1",
SpeakerID: "speaker-1",
ChatID: "chat-1",
Channel: "discord",
Sequence: 999,
Format: "opus",
Data: []byte{0x01},
})
if !errors.Is(err, ErrBusBackpressure) {
t.Fatalf("PublishAudioChunk() error = %v, want %v", err, ErrBusBackpressure)
}
evt := receiveBusRuntimeEvent(t, eventsCh)
if evt.Kind != runtimeevents.KindBusMessageDropped ||
evt.Source.Name != "audio_chunk" ||
evt.Severity != runtimeevents.SeverityWarn {
t.Fatalf("drop event = %+v", evt)
}
if evt.Scope.Channel != "discord" || evt.Scope.ChatID != "chat-1" {
t.Fatalf("drop event scope = %+v", evt.Scope)
}
if evt.Attrs["stream"] != "audio_chunk" ||
evt.Attrs["reason"] != "queue_full_timeout" ||
evt.Attrs["wait_ms"] != defaultAudioPublishTimeout.Milliseconds() ||
evt.Attrs["queue_depth"] != defaultBusBufferSize*4 ||
evt.Attrs["queue_capacity"] != defaultBusBufferSize*4 ||
evt.Attrs["dropped_total"] != uint64(1) {
t.Fatalf("drop event attrs = %#v", evt.Attrs)
}
stats := mb.Stats()
if stats.AudioChunks.DroppedTotal != 1 {
t.Fatalf("AudioChunks dropped = %d, want 1", stats.AudioChunks.DroppedTotal)
}
if stats.AudioChunks.Depth != defaultBusBufferSize*4 {
t.Fatalf("AudioChunks depth = %d, want %d", stats.AudioChunks.Depth, defaultBusBufferSize*4)
}
wantWaitMS := defaultAudioPublishTimeout.Milliseconds()
if stats.AudioChunks.LastDropWaitMillis != wantWaitMS {
t.Fatalf("AudioChunks last wait ms = %d, want %d", stats.AudioChunks.LastDropWaitMillis, wantWaitMS)
}
}
func TestPublishVoiceControlSubscribe(t *testing.T) {
mb := NewMessageBus()
defer mb.Close()
@@ -728,6 +807,74 @@ func TestPublishInbound_FullBuffer(t *testing.T) {
}
}
// TestPublishInbound_FullBufferUsesBusBackpressureBudget exercises the generic
// publish() backpressure path directly (with a short 20ms timeout) rather than
// going through PublishInbound(). This avoids waiting for a long context timeout
// and keeps the test fast. Context validation and public-API wiring are covered
// by TestPublishInbound_FullBuffer and TestPublishInbound_ContextCancel.
func TestPublishInbound_FullBufferUsesBusBackpressureBudget(t *testing.T) {
mb := NewMessageBus()
defer mb.Close()
ch := make(chan InboundMessage, 1)
ch <- InboundMessage{Content: "fill"}
scope := runtimeevents.Scope{Channel: "test", ChatID: "chat-overflow"}
err := publish(context.Background(), mb, ch, InboundMessage{Content: "overflow"}, publishPolicy{
stream: "inbound",
timeout: 20 * time.Millisecond,
}, &mb.inboundStats, scope)
if !errors.Is(err, ErrBusBackpressure) {
t.Fatalf("publish() error = %v, want %v", err, ErrBusBackpressure)
}
stats := mb.Stats()
if stats.Inbound.DroppedTotal != 1 {
t.Fatalf("Inbound dropped = %d, want 1", stats.Inbound.DroppedTotal)
}
if stats.Inbound.LastDropWaitMillis != 20 {
t.Fatalf("Inbound last wait ms = %d, want 20", stats.Inbound.LastDropWaitMillis)
}
}
func TestMessageBusHealthCheckIncludesQueueDepthAndDrops(t *testing.T) {
mb := NewMessageBus()
defer mb.Close()
ok, msg := mb.HealthCheck()
if !ok {
t.Fatal("HealthCheck should remain ok for backpressure telemetry")
}
if msg == "" {
t.Fatal("HealthCheck message should not be empty")
}
for i := range cap(mb.audioChunks) {
if err := mb.PublishAudioChunk(context.Background(), AudioChunk{
Channel: "discord",
ChatID: "voice-room",
Sequence: uint64(i),
Data: []byte("fill"),
}); err != nil {
t.Fatalf("fill audio buffer at %d: %v", i, err)
}
}
_ = mb.PublishAudioChunk(context.Background(), AudioChunk{
Channel: "discord",
ChatID: "voice-room",
Sequence: 999,
Data: []byte("overflow"),
})
stats := mb.Stats()
if stats.AudioChunks.Depth != cap(mb.audioChunks) {
t.Fatalf("audio depth = %d, want %d", stats.AudioChunks.Depth, cap(mb.audioChunks))
}
if stats.AudioChunks.DroppedTotal != 1 {
t.Fatalf("audio dropped = %d, want 1", stats.AudioChunks.DroppedTotal)
}
}
func TestCloseIdempotent(t *testing.T) {
mb := NewMessageBus()
+51
View File
@@ -1,6 +1,8 @@
package bus
import (
"time"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
)
@@ -13,6 +15,15 @@ type busClosePayload struct {
Drained int `json:"drained,omitempty"`
}
type busMessageDroppedPayload struct {
Stream string `json:"stream"`
Reason string `json:"reason"`
WaitMS int64 `json:"wait_ms"`
QueueDepth int `json:"queue_depth"`
QueueCap int `json:"queue_capacity"`
DroppedTotal uint64 `json:"dropped_total"`
}
func (mb *MessageBus) publishFailure(stream string, scope runtimeevents.Scope, err error) {
if mb == nil || err == nil {
return
@@ -38,6 +49,46 @@ func (mb *MessageBus) publishFailure(stream string, scope runtimeevents.Scope, e
})
}
func (mb *MessageBus) publishDrop(
stream string,
scope runtimeevents.Scope,
reason string,
wait time.Duration,
queueDepth, queueCap int,
droppedTotal uint64,
) {
if mb == nil {
return
}
publisher, ok := mb.eventPublisher.Load().(EventPublisher)
if !ok || publisher == nil {
return
}
publisher.PublishNonBlocking(runtimeevents.Event{
Kind: runtimeevents.KindBusMessageDropped,
Source: runtimeevents.Source{Component: "bus", Name: stream},
Scope: scope,
Severity: runtimeevents.SeverityWarn,
Payload: busMessageDroppedPayload{
Stream: stream,
Reason: reason,
WaitMS: wait.Milliseconds(),
QueueDepth: queueDepth,
QueueCap: queueCap,
DroppedTotal: droppedTotal,
},
Attrs: map[string]any{
"stream": stream,
"reason": reason,
"wait_ms": wait.Milliseconds(),
"queue_depth": queueDepth,
"queue_capacity": queueCap,
"dropped_total": droppedTotal,
},
})
}
func (mb *MessageBus) publishCloseEvent(kind runtimeevents.Kind, drained int) {
if mb == nil {
return
+6 -4
View File
@@ -64,10 +64,12 @@ type OutboundScope struct {
// ContextUsage describes how much of the model's context window the current
// session consumes, and how far it is from triggering compression.
type ContextUsage struct {
UsedTokens int `json:"used_tokens"`
TotalTokens int `json:"total_tokens"` // model context window
CompressAtTokens int `json:"compress_at_tokens"` // threshold that triggers compression
UsedPercent int `json:"used_percent"` // 0-100
UsedTokens int `json:"used_tokens"`
TotalTokens int `json:"total_tokens"` // model context window
HistoryTokens int `json:"history_tokens"` // history-message tokens only (what maybeSummarize checks)
CompressAtTokens int `json:"compress_at_tokens"` // hard budget compression threshold (contextWindow - maxTokens)
SummarizeAtTokens int `json:"summarize_at_tokens"` // soft summarization trigger (vs history tokens)
UsedPercent int `json:"used_percent"` // 0-100, relative to compressAt
}
type OutboundMessage struct {
+7 -5
View File
@@ -266,7 +266,7 @@ func (c *BaseChannel) HandleMessageWithContext(
media []string,
inboundCtx bus.InboundContext,
senderOpts ...bus.SenderInfo,
) {
) error {
// Use SenderInfo-based allow check when available, else fall back to string
var sender bus.SenderInfo
if len(senderOpts) > 0 {
@@ -275,11 +275,11 @@ func (c *BaseChannel) HandleMessageWithContext(
senderID := strings.TrimSpace(inboundCtx.SenderID)
if sender.CanonicalID != "" || sender.PlatformID != "" {
if !c.IsAllowedSender(sender) {
return
return nil
}
} else {
if !c.IsAllowed(senderID) {
return
return nil
}
}
@@ -350,7 +350,9 @@ func (c *BaseChannel) HandleMessageWithContext(
"chat_id": deliveryChatID,
"error": err.Error(),
})
return err
}
return nil
}
// HandleInboundContext publishes a normalized inbound message using only the
@@ -361,8 +363,8 @@ func (c *BaseChannel) HandleInboundContext(
media []string,
inboundCtx bus.InboundContext,
senderOpts ...bus.SenderInfo,
) {
c.HandleMessageWithContext(ctx, deliveryChatID, content, media, inboundCtx, senderOpts...)
) error {
return c.HandleMessageWithContext(ctx, deliveryChatID, content, media, inboundCtx, senderOpts...)
}
func (c *BaseChannel) SetRunning(running bool) {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+398
View File
@@ -0,0 +1,398 @@
package deltachat
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"unicode"
"github.com/google/uuid"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/utils"
)
// listen is the inbound message loop. It blocks on wait_next_msgs and feeds
// each new message into the PicoClaw inbound pipeline.
func (c *DeltaChatChannel) listen() {
logger.InfoCF("deltachat", "Listening for messages", map[string]any{
"account_id": c.accountID,
"email": c.selfAddr,
})
for c.IsRunning() && c.ctx.Err() == nil {
raw, err := c.rpc.call(c.ctx, "wait_next_msgs", c.accountID)
if err != nil {
if c.ctx.Err() != nil || !c.IsRunning() {
return
}
logger.ErrorCF("deltachat", "wait_next_msgs failed", map[string]any{"error": err.Error()})
time.Sleep(time.Second)
continue
}
var messageIDs []int64
if err := json.Unmarshal(raw, &messageIDs); err != nil {
continue
}
if len(messageIDs) > 0 {
logger.DebugCF("deltachat", "Received message batch", map[string]any{
"count": len(messageIDs),
})
}
for _, messageID := range messageIDs {
c.handleMessage(messageID)
}
}
}
// handleMessage fetches one message, applies inbound filtering, and publishes it.
func (c *DeltaChatChannel) handleMessage(messageID int64) {
msg, err := c.getMessage(messageID)
if err != nil {
logger.DebugCF("deltachat", "get_message failed", map[string]any{
"message_id": messageID,
"error": err.Error(),
})
return
}
if msg.IsInfo || (strings.TrimSpace(msg.Text) == "" && msg.File == "") {
return
}
senderAddr := ""
if msg.Sender != nil {
senderAddr = msg.Sender.Address
}
if senderAddr != "" && strings.EqualFold(senderAddr, c.selfAddr) {
logger.DebugCF("deltachat", "Drop: own message", map[string]any{"message_id": messageID})
return
}
chat, err := c.getFullChat(msg.ChatID)
if err != nil {
logger.DebugCF("deltachat", "get_full_chat_by_id failed", map[string]any{
"chat_id": msg.ChatID,
"error": err.Error(),
})
return
}
// Device messages are core-generated notices, not real conversations.
if chat.IsDeviceChat {
logger.DebugCF("deltachat", "Drop: device message", map[string]any{"chat_id": msg.ChatID})
return
}
isGroup := chat.ChatType != chatTypeSingle
logger.DebugCF("deltachat", "Inbound message", map[string]any{
"message_id": messageID,
"chat_id": msg.ChatID,
"from": senderAddr,
"is_group": isGroup,
"has_file": msg.File != "",
"text_len": len(strings.TrimSpace(msg.Text)),
})
senderName := senderAddr
if msg.Sender != nil {
if msg.Sender.DisplayName != "" {
senderName = msg.Sender.DisplayName
} else if msg.Sender.Name != "" {
senderName = msg.Sender.Name
}
}
if senderName == "" {
senderName = "unknown"
}
chatID := strconv.FormatInt(msg.ChatID, 10)
messageIDStr := strconv.FormatInt(msg.ID, 10)
content := strings.TrimSpace(msg.Text)
// Register any attachment with the media store so the agent pipeline can
// view images and operate on files. The ref is scoped to the same key the
// BaseChannel derives for this message, so it is released with the turn.
var mediaRefs []string
if msg.File != "" {
scope := channels.BuildMediaScope(c.Name(), chatID, messageIDStr)
if ref := c.registerInboundFile(scope, msg); ref != "" {
mediaRefs = append(mediaRefs, ref)
} else {
// Fallback when no media store is available: surface the path inline
// so the attachment is not silently lost.
annotation := fmt.Sprintf("[attachment: %s]", msg.File)
if content == "" {
content = annotation
} else {
content = content + "\n" + annotation
}
}
}
// A file with no caption still warrants a turn; give the agent a minimal
// placeholder so the message survives the empty-content guard below. Audio
// gets a "[voice]" tag specifically, so the agent's transcription step can
// substitute the transcript in place rather than appending it.
if content == "" && len(mediaRefs) > 0 {
if utils.IsAudioFile(msg.FileName, msg.FileMime) {
content = "[voice]"
} else {
content = "[media]"
}
}
sender := bus.SenderInfo{
Platform: config.ChannelDeltaChat,
PlatformID: senderAddr,
CanonicalID: identity.BuildCanonicalID(config.ChannelDeltaChat, senderAddr),
Username: senderAddr,
DisplayName: senderName,
}
if !c.IsAllowedSender(sender) {
logger.DebugCF("deltachat", "Drop: sender not in allow_from", map[string]any{
"from": senderAddr,
})
return
}
isMentioned := false
if isGroup {
botName := c.config.DisplayName
if botName == "" {
botName = c.selfAddr
}
isMentioned = mentionsBot(content, botName, c.selfAddr)
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
if !respond {
logger.DebugCF("deltachat", "Drop: group trigger not satisfied", map[string]any{
"chat_id": msg.ChatID,
"mentioned": isMentioned,
})
return
}
content = cleaned
}
if strings.TrimSpace(content) == "" {
return
}
metadata := map[string]string{
"platform": config.ChannelDeltaChat,
"chat_name": chat.Name,
}
if msg.File != "" {
metadata["file"] = msg.File
metadata["file_name"] = msg.FileName
metadata["file_mime"] = msg.FileMime
}
inboundCtx := bus.InboundContext{
Channel: config.ChannelDeltaChat,
ChatID: chatID,
SenderID: senderAddr,
MessageID: messageIDStr,
Mentioned: isMentioned,
Raw: metadata,
}
if isGroup {
inboundCtx.ChatType = "group"
} else {
inboundCtx.ChatType = "direct"
}
logger.DebugCF("deltachat", "Dispatching to agent", map[string]any{
"chat_id": chatID,
"chat_type": inboundCtx.ChatType,
"from": senderAddr,
})
if err := c.HandleInboundContext(c.ctx, chatID, content, mediaRefs, inboundCtx, sender); err != nil {
logger.ErrorCF("deltachat", "Dispatch failed; leaving message unseen", map[string]any{
"message_id": messageID,
"chat_id": chatID,
"error": err.Error(),
})
return
}
if _, err := c.rpc.call(c.ctx, "markseen_msgs", c.accountID, []int64{messageID}); err != nil {
logger.WarnCF("deltachat", "Failed to mark message seen", map[string]any{
"message_id": messageID,
"chat_id": chatID,
"error": err.Error(),
})
}
}
// registerInboundFile records an inbound attachment with the media store under
// the given scope and returns its media:// ref. Returns "" when there is no
// media store or registration fails, letting the caller fall back to an inline
// path annotation.
//
// Delta Chat stores attachments inside the account directory, next to the
// credential database — a location tools are intentionally NOT allowed to read.
// We therefore copy the single attachment out into the shared media temp dir
// (which read_file/load_image are permitted to access) and register that copy,
// so the agent can actually open the file. The copy is store-managed and deleted
// when the turn's scope is released.
func (c *DeltaChatChannel) registerInboundFile(scope string, msg *dcMessage) string {
store := c.GetMediaStore()
if store == nil {
return ""
}
filename := msg.FileName
if filename == "" {
filename = filepath.Base(msg.File)
}
localPath, err := copyToMediaTemp(msg.File, filename)
if err != nil {
logger.WarnCF("deltachat", "Failed to copy attachment into media dir", map[string]any{
"file": msg.File,
"error": err.Error(),
})
return ""
}
ref, err := store.Store(localPath, media.MediaMeta{
Filename: filename,
ContentType: msg.FileMime,
Source: config.ChannelDeltaChat,
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
}, scope)
if err != nil {
logger.WarnCF("deltachat", "Failed to register attachment with media store", map[string]any{
"file": localPath,
"error": err.Error(),
})
_ = os.Remove(localPath)
return ""
}
return ref
}
// copyToMediaTemp copies srcPath into the shared media temp directory under a
// unique name and returns the destination path. The media temp dir is the
// location the read_file/load_image tools are permitted to read, so copying here
// makes the attachment readable without exposing Delta Chat's account directory.
func copyToMediaTemp(srcPath, filename string) (string, error) {
if err := os.MkdirAll(media.TempDir(), 0o700); err != nil {
return "", err
}
safe := utils.SanitizeFilename(filename)
if safe == "" {
safe = filepath.Base(srcPath)
}
dstPath := filepath.Join(media.TempDir(), uuid.NewString()[:8]+"_"+safe)
src, err := os.Open(srcPath)
if err != nil {
return "", err
}
defer src.Close()
dst, err := os.Create(dstPath)
if err != nil {
return "", err
}
if _, err := io.Copy(dst, src); err != nil {
dst.Close()
_ = os.Remove(dstPath)
return "", err
}
if err := dst.Close(); err != nil {
_ = os.Remove(dstPath)
return "", err
}
return dstPath, nil
}
func (c *DeltaChatChannel) getMessage(messageID int64) (*dcMessage, error) {
raw, err := c.rpc.call(c.ctx, "get_message", c.accountID, messageID)
if err != nil {
return nil, err
}
var msg dcMessage
if err := json.Unmarshal(raw, &msg); err != nil {
return nil, err
}
return &msg, nil
}
func (c *DeltaChatChannel) getFullChat(chatID int64) (*dcChat, error) {
raw, err := c.rpc.call(c.ctx, "get_full_chat_by_id", c.accountID, chatID)
if err != nil {
return nil, err
}
var chat dcChat
if err := json.Unmarshal(raw, &chat); err != nil {
return nil, err
}
return &chat, nil
}
// mentionsBot reports whether the message references the bot by display name or
// the local-part of its email address (a common addressing convention).
func mentionsBot(content, displayName, email string) bool {
if containsMentionToken(content, displayName) {
return true
}
if local, _, ok := strings.Cut(email, "@"); ok && local != "" {
if containsMentionToken(content, "@"+local) {
return true
}
}
return false
}
func containsMentionToken(content, token string) bool {
token = strings.TrimSpace(token)
if token == "" {
return false
}
contentRunes := []rune(strings.ToLower(content))
tokenRunes := []rune(strings.ToLower(token))
if len(tokenRunes) == 0 || len(tokenRunes) > len(contentRunes) {
return false
}
for i := 0; i <= len(contentRunes)-len(tokenRunes); i++ {
if !sameRunes(contentRunes[i:i+len(tokenRunes)], tokenRunes) {
continue
}
before := i == 0 || !isMentionWordRune(contentRunes[i-1])
afterIdx := i + len(tokenRunes)
after := afterIdx >= len(contentRunes) || !isMentionWordRune(contentRunes[afterIdx])
if before && after {
return true
}
}
return false
}
func sameRunes(a, b []rune) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func isMentionWordRune(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
}
+35
View File
@@ -0,0 +1,35 @@
package deltachat
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory(
config.ChannelDeltaChat,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
if bc == nil || !bc.Enabled {
return nil, nil
}
decoded, err := bc.GetDecoded()
if err != nil {
return nil, err
}
c, ok := decoded.(*config.DeltaChatSettings)
if !ok {
return nil, channels.ErrSendFailed
}
ch, err := NewDeltaChatChannel(bc, c, b)
if err != nil {
return nil, err
}
if channelName != config.ChannelDeltaChat {
ch.SetName(channelName)
}
return ch, nil
},
)
}
+188
View File
@@ -0,0 +1,188 @@
package deltachat
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os/exec"
"sync"
"github.com/sipeed/picoclaw/pkg/logger"
)
// rpcRequest is a single JSON-RPC 2.0 request. Delta Chat uses positional
// (array) parameters.
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID uint64 `json:"id"`
Method string `json:"method"`
Params []any `json:"params,omitempty"`
}
// rpcResponse is a single JSON-RPC 2.0 response.
type rpcResponse struct {
ID uint64 `json:"id"`
Result json.RawMessage `json:"result"`
Error *rpcError `json:"error"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (e *rpcError) Error() string {
return fmt.Sprintf("deltachat rpc error %d: %s", e.Code, e.Message)
}
// rpcClient drives a `deltachat-rpc-server` child process over stdio using
// newline-delimited JSON-RPC 2.0. The server answers requests asynchronously
// and out of order, so every call is correlated back to its caller by id.
type rpcClient struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
mu sync.Mutex
nextID uint64
pending map[uint64]chan rpcResponse
closed bool
}
// startRPC spawns the RPC server with DC_ACCOUNTS_PATH pointing at dataDir and
// begins the background read loop.
func startRPC(serverPath, dataDir string) (*rpcClient, error) {
cmd := exec.Command(serverPath)
cmd.Env = append(cmd.Environ(), "DC_ACCOUNTS_PATH="+dataDir)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("deltachat rpc stdin: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("deltachat rpc stdout: %w", err)
}
// Let the server's logs flow to our stderr for easy diagnostics.
cmd.Stderr = logWriter{}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("start deltachat-rpc-server (%s): %w", serverPath, err)
}
c := &rpcClient{
cmd: cmd,
stdin: stdin,
stdout: stdout,
pending: make(map[uint64]chan rpcResponse),
}
go c.readLoop()
return c, nil
}
// readLoop reads newline-delimited responses and dispatches them to waiters.
func (c *rpcClient) readLoop() {
reader := bufio.NewReader(c.stdout)
for {
line, err := reader.ReadBytes('\n')
if len(line) > 0 {
var resp rpcResponse
if jsonErr := json.Unmarshal(line, &resp); jsonErr == nil && resp.ID != 0 {
c.mu.Lock()
ch := c.pending[resp.ID]
delete(c.pending, resp.ID)
c.mu.Unlock()
if ch != nil {
ch <- resp
}
}
}
if err != nil {
c.failAll(err)
return
}
}
}
// failAll wakes every pending caller with an error (used on EOF / shutdown).
func (c *rpcClient) failAll(err error) {
c.mu.Lock()
defer c.mu.Unlock()
c.closed = true
for id, ch := range c.pending {
ch <- rpcResponse{Error: &rpcError{Code: -1, Message: "rpc closed: " + err.Error()}}
delete(c.pending, id)
}
}
// call issues one request and blocks until the matching response arrives, the
// context is canceled, or the server goes away.
func (c *rpcClient) call(ctx context.Context, method string, params ...any) (json.RawMessage, error) {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil, fmt.Errorf("deltachat rpc: connection closed")
}
c.nextID++
id := c.nextID
ch := make(chan rpcResponse, 1)
c.pending[id] = ch
c.mu.Unlock()
req := rpcRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params}
data, err := json.Marshal(req)
if err != nil {
c.clearPending(id)
return nil, err
}
data = append(data, '\n')
c.mu.Lock()
_, err = c.stdin.Write(data)
c.mu.Unlock()
if err != nil {
c.clearPending(id)
return nil, fmt.Errorf("deltachat rpc write %s: %w", method, err)
}
select {
case <-ctx.Done():
c.clearPending(id)
return nil, ctx.Err()
case resp := <-ch:
if resp.Error != nil {
return nil, resp.Error
}
return resp.Result, nil
}
}
func (c *rpcClient) clearPending(id uint64) {
c.mu.Lock()
delete(c.pending, id)
c.mu.Unlock()
}
// close terminates the RPC server process.
func (c *rpcClient) close() {
c.mu.Lock()
c.closed = true
c.mu.Unlock()
if c.stdin != nil {
_ = c.stdin.Close()
}
if c.cmd != nil && c.cmd.Process != nil {
_ = c.cmd.Process.Kill()
_ = c.cmd.Wait()
}
}
// logWriter forwards deltachat-rpc-server stderr lines into the logger.
type logWriter struct{}
func (logWriter) Write(p []byte) (int, error) {
logger.DebugC("deltachat", string(p))
return len(p), nil
}
+37 -17
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
@@ -605,10 +606,11 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
scope := channels.BuildMediaScope("discord", m.ChannelID, m.ID)
// Helper to register a local file with the media store
storeMedia := func(localPath, filename string) string {
storeMedia := func(localPath string, attachment *discordgo.MessageAttachment) string {
if store := c.GetMediaStore(); store != nil {
ref, err := store.Store(localPath, media.MediaMeta{
Filename: filename,
Filename: attachment.Filename,
ContentType: attachment.ContentType,
Source: "discord",
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
}, scope)
@@ -620,22 +622,16 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
}
for _, attachment := range m.Attachments {
isAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType)
if isAudio {
localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
if localPath != "" {
mediaPaths = append(mediaPaths, storeMedia(localPath, attachment.Filename))
content = appendContent(content, fmt.Sprintf("[audio: %s]", attachment.Filename))
} else {
logger.WarnCF("discord", "Failed to download audio attachment", map[string]any{
"url": attachment.URL,
"filename": attachment.Filename,
})
mediaPaths = append(mediaPaths, attachment.URL)
content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
}
localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
if localPath != "" {
mediaPaths = append(mediaPaths, storeMedia(localPath, attachment))
tag := attachmentMediaTag(attachment.Filename, attachment.ContentType)
content = appendContent(content, fmt.Sprintf("[%s: %s]", tag, attachment.Filename))
} else {
logger.WarnCF("discord", "Failed to download attachment", map[string]any{
"url": attachment.URL,
"filename": attachment.Filename,
})
mediaPaths = append(mediaPaths, attachment.URL)
content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
}
@@ -748,6 +744,30 @@ func (c *DiscordChannel) downloadAttachment(url, filename string) string {
})
}
func attachmentMediaTag(filename, contentType string) string {
ct := strings.ToLower(contentType)
switch {
case strings.HasPrefix(ct, "image/"):
return "image"
case strings.HasPrefix(ct, "audio/"), ct == "application/ogg", ct == "application/x-ogg":
return "audio"
case strings.HasPrefix(ct, "video/"):
return "video"
}
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp":
return "image"
case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma":
return "audio"
case ".mp4", ".avi", ".mov", ".webm", ".mkv":
return "video"
}
return "file"
}
func applyDiscordProxy(session *discordgo.Session, proxyAddr string) error {
var proxyFunc func(*http.Request) (*url.URL, error)
if proxyAddr != "" {
+5 -3
View File
@@ -298,9 +298,11 @@ func (c *DiscordChannel) receiveVoice(vc *discordgo.VoiceConnection, guildID str
Data: p.Opus,
}
ctx, cancel := context.WithTimeout(c.ctx, 100*time.Millisecond)
err := c.bus.PublishAudioChunk(ctx, chunk)
cancel()
// Pass the parent context directly; the bus applies its own
// audio drop budget internally.
// A 100ms caller-level timeout would fire before the bus's
// backpressure handling, masking drop counters and events.
err := c.bus.PublishAudioChunk(c.ctx, chunk)
if err != nil {
logger.ErrorCF("discord", "Failed to publish audio chunk", map[string]any{
"guild": guildID,
+4 -4
View File
@@ -11,11 +11,11 @@ import (
func ClassifySendError(statusCode int, rawErr error) error {
switch {
case statusCode == http.StatusTooManyRequests:
return fmt.Errorf("%w: %v", ErrRateLimit, rawErr)
return fmt.Errorf("%w: %w", ErrRateLimit, rawErr)
case statusCode >= 500:
return fmt.Errorf("%w: %v", ErrTemporary, rawErr)
return fmt.Errorf("%w: %w", ErrTemporary, rawErr)
case statusCode >= 400:
return fmt.Errorf("%w: %v", ErrSendFailed, rawErr)
return fmt.Errorf("%w: %w", ErrSendFailed, rawErr)
default:
return rawErr
}
@@ -26,5 +26,5 @@ func ClassifyNetError(err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%w: %v", ErrTemporary, err)
return fmt.Errorf("%w: %w", ErrTemporary, err)
}
+36 -9
View File
@@ -52,6 +52,8 @@ type FeishuChannel struct {
progress *channels.ToolFeedbackAnimator
deleteMessageFn func(context.Context, string, string) error
sendMediaPartFn func(context.Context, string, bus.MediaPart, media.MediaStore) error
sendTextFn func(context.Context, string, string) (string, error)
}
type cachedMessage struct {
@@ -78,6 +80,8 @@ func NewFeishuChannel(bc *config.Channel, cfg *config.FeishuSettings, bus *bus.M
client: lark.NewClient(cfg.AppID, cfg.AppSecret.String(), opts...),
}
ch.deleteMessageFn = ch.deleteMessageAPI
ch.sendMediaPartFn = ch.sendMediaPart
ch.sendTextFn = ch.sendText
ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
ch.SetOwner(ch)
return ch, nil
@@ -304,7 +308,7 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str
}
req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(larkim.ReceiveIdTypeChatId).
ReceiveIdType(larkim.CreateMessageV1ReceiveIDTypeChatId).
Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(chatID).
MsgType(larkim.MsgTypeInteractive).
@@ -497,8 +501,16 @@ func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
}
caption := firstMediaCaption(msg.Parts)
sentAny := false
for _, part := range msg.Parts {
if err := c.sendMediaPart(ctx, msg.ChatID, part, store); err != nil {
if err := c.sendMediaPartFn(ctx, msg.ChatID, part, store); err != nil {
return nil, err
}
sentAny = true
}
if sentAny && caption != "" {
if _, err := c.sendTextFn(ctx, msg.ChatID, caption); err != nil {
return nil, err
}
}
@@ -557,6 +569,15 @@ func (c *FeishuChannel) sendMediaPart(
return nil
}
func firstMediaCaption(parts []bus.MediaPart) string {
for _, part := range parts {
if caption := strings.TrimSpace(part.Caption); caption != "" {
return caption
}
}
return ""
}
// --- Inbound message handling ---
func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
@@ -725,8 +746,8 @@ func (c *FeishuChannel) isBotMentioned(message *larkim.EventMessage) bool {
return false
}
knownID, _ := c.botOpenID.Load().(string)
if knownID == "" {
knownID, ok := c.botOpenID.Load().(string)
if !ok || knownID == "" {
logger.DebugCF("feishu", "Bot open_id unknown, cannot detect @mention", nil)
return false
}
@@ -992,7 +1013,13 @@ func (c *FeishuChannel) storeResourceFile(
})
return ""
}
out.Close()
if closeErr := out.Close(); closeErr != nil {
logger.ErrorCF("feishu", "Failed to close downloaded resource file", map[string]any{
"error": closeErr.Error(),
})
os.Remove(localPath)
return ""
}
ref, err := store.Store(localPath, media.MediaMeta{
Filename: filename,
@@ -1047,7 +1074,7 @@ func appendMediaTags(content, messageType string, mediaRefs []string) string {
// sendCard sends an interactive card message to a chat.
func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) (string, error) {
req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(larkim.ReceiveIdTypeChatId).
ReceiveIdType(larkim.CreateMessageV1ReceiveIDTypeChatId).
Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(chatID).
MsgType(larkim.MsgTypeInteractive).
@@ -1080,7 +1107,7 @@ func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) (stri
content, _ := json.Marshal(map[string]string{"text": text})
req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(larkim.ReceiveIdTypeChatId).
ReceiveIdType(larkim.CreateMessageV1ReceiveIDTypeChatId).
Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(chatID).
MsgType(larkim.MsgTypeText).
@@ -1134,7 +1161,7 @@ func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.F
// Send image message
content, _ := json.Marshal(map[string]string{"image_key": imageKey})
req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(larkim.ReceiveIdTypeChatId).
ReceiveIdType(larkim.CreateMessageV1ReceiveIDTypeChatId).
Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(chatID).
MsgType(larkim.MsgTypeImage).
@@ -1190,7 +1217,7 @@ func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.Fi
// Send file message
content, _ := json.Marshal(map[string]string{"file_key": fileKey})
req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(larkim.ReceiveIdTypeChatId).
ReceiveIdType(larkim.CreateMessageV1ReceiveIDTypeChatId).
Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(chatID).
MsgType(larkim.MsgTypeFile).
+39
View File
@@ -9,7 +9,9 @@ import (
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/media"
)
func TestExtractContent(t *testing.T) {
@@ -319,6 +321,43 @@ func TestFinalizeTrackedToolFeedbackMessage_ClearAfterSuccessfulEdit(t *testing.
}
}
func TestSendMedia_SendsCaptionFallbackAfterMedia(t *testing.T) {
ch := &FeishuChannel{
BaseChannel: channels.NewBaseChannel("feishu", nil, nil, nil),
progress: channels.NewToolFeedbackAnimator(nil),
}
ch.SetRunning(true)
ch.SetMediaStore(media.NewFileMediaStore())
var mediaOrder []string
var textCalls []string
ch.sendMediaPartFn = func(ctx context.Context, chatID string, part bus.MediaPart, store media.MediaStore) error {
mediaOrder = append(mediaOrder, part.Type)
return nil
}
ch.sendTextFn = func(ctx context.Context, chatID, text string) (string, error) {
textCalls = append(textCalls, chatID+"|"+text)
return "msg-1", nil
}
_, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "oc_123",
Parts: []bus.MediaPart{
{Type: "image", Caption: "shared caption"},
{Type: "file"},
},
})
if err != nil {
t.Fatalf("SendMedia() error = %v", err)
}
if len(mediaOrder) != 2 {
t.Fatalf("media sends = %v, want 2 sends", mediaOrder)
}
if len(textCalls) != 1 || textCalls[0] != "oc_123|shared caption" {
t.Fatalf("textCalls = %v, want [oc_123|shared caption]", textCalls)
}
}
func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) {
ch := &FeishuChannel{
progress: channels.NewToolFeedbackAnimator(nil),
+4 -3
View File
@@ -69,7 +69,8 @@ func NewLINEChannel(
return nil, fmt.Errorf("failed to create LINE messaging client: %w", err)
}
base := channels.NewBaseChannel("line", cfg, messageBus, bc.AllowFrom,
base := channels.NewBaseChannel(
"line", cfg, messageBus, bc.AllowFrom,
channels.WithMaxMessageLength(5000),
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
@@ -482,7 +483,7 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
Messages: []messaging_api.MessageInterface{&textMsg},
})
if resp != nil && resp.Body != nil {
resp.Body.Close()
_ = resp.Body.Close()
}
if err == nil {
logger.DebugCF("line", "Message sent via Reply API", map[string]any{
@@ -588,7 +589,7 @@ func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), e
// classifySDKError maps an SDK HTTP response to the project's sentinel errors.
func classifySDKError(resp *http.Response, err error) error {
if resp != nil && resp.Body != nil {
resp.Body.Close()
_ = resp.Body.Close()
}
if err == nil {
return nil
+79 -10
View File
@@ -13,6 +13,7 @@ import (
"math"
"net"
"net/http"
"runtime/debug"
"sort"
"strings"
"sync"
@@ -169,6 +170,13 @@ func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool {
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback")
}
func outboundMessageIsToolCalls(msg bus.OutboundMessage) bool {
if len(msg.Context.Raw) == 0 {
return false
}
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_calls")
}
func outboundMessageHasAuxiliaryKind(msg bus.OutboundMessage) bool {
if len(msg.Context.Raw) == 0 {
return false
@@ -379,6 +387,7 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
}
isToolFeedback := outboundMessageIsToolFeedback(msg)
isToolCalls := outboundMessageIsToolCalls(msg)
isAuxiliaryMessage := outboundMessageHasAuxiliaryKind(msg)
isFinalMessage := outboundMessageIsFinal(msg)
separateToolFeedbackMessages := m.toolFeedbackSeparateMessagesEnabled()
@@ -388,7 +397,9 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
// finalization bypasses the worker queue, so older queued feedback/thoughts
// can arrive before the normal final outbound message that cleans up the
// marker and placeholder.
if isAuxiliaryMessage {
// Note: tool_calls messages must NOT be dropped as they represent new tool
// invocations for the current turn that must be delivered to the UI.
if isAuxiliaryMessage && !isToolCalls {
if _, loaded := m.streamActive.Load(streamKey); loaded {
return nil, true
}
@@ -688,18 +699,34 @@ func setStreamerModelName(streamer any, modelName string) {
setter.SetModelName(modelName)
}
type turnUsageStreamer interface {
SetTurnUsage(inputTokens, outputTokens int)
}
// setStreamerTurnUsage forwards real per-turn token usage to a streamer that
// supports it, transparently unwrapping the manager's streamer wrappers.
func setStreamerTurnUsage(streamer any, inputTokens, outputTokens int) {
setter, ok := streamer.(turnUsageStreamer)
if !ok {
return
}
setter.SetTurnUsage(inputTokens, outputTokens)
}
// splitMarkerStreamer turns accumulated streaming text containing
// MessageSplitMarker into separate channel stream messages.
type splitMarkerStreamer struct {
mu sync.Mutex
current bus.Streamer
reasoning bus.ReasoningStreamer
begin func(context.Context) (bus.Streamer, error)
completedParts int
finalized bool
onFinalize func(context.Context, string)
clearMarker func()
modelName string
mu sync.Mutex
current bus.Streamer
reasoning bus.ReasoningStreamer
begin func(context.Context) (bus.Streamer, error)
completedParts int
finalized bool
onFinalize func(context.Context, string)
clearMarker func()
modelName string
turnInputTokens int
turnOutputTokens int
}
func (s *splitMarkerStreamer) Update(ctx context.Context, content string) error {
@@ -750,6 +777,14 @@ func (s *splitMarkerStreamer) SetModelName(modelName string) {
setStreamerModelName(s.reasoning, s.modelName)
}
func (s *splitMarkerStreamer) SetTurnUsage(inputTokens, outputTokens int) {
s.mu.Lock()
defer s.mu.Unlock()
s.turnInputTokens = inputTokens
s.turnOutputTokens = outputTokens
setStreamerTurnUsage(s.current, s.turnInputTokens, s.turnOutputTokens)
}
func (s *splitMarkerStreamer) Cancel(ctx context.Context) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -829,6 +864,7 @@ func (s *splitMarkerStreamer) ensureCurrentLocked(ctx context.Context) error {
}
s.current = streamer
setStreamerModelName(s.current, s.modelName)
setStreamerTurnUsage(s.current, s.turnInputTokens, s.turnOutputTokens)
return nil
}
@@ -917,6 +953,10 @@ func (s *finalizeHookStreamer) SetModelName(modelName string) {
setStreamerModelName(s.Streamer, strings.TrimSpace(modelName))
}
func (s *finalizeHookStreamer) SetTurnUsage(inputTokens, outputTokens int) {
setStreamerTurnUsage(s.Streamer, inputTokens, outputTokens)
}
func (s *finalizeHookStreamer) runFinalizeHook(ctx context.Context, content string) {
if s.onFinalize != nil {
s.onFinalize(ctx, content)
@@ -1269,6 +1309,16 @@ func (m *Manager) StartAll(ctx context.Context) error {
for _, listener := range m.httpListeners {
ln := listener
go func() {
defer func() {
if r := recover(); r != nil {
logger.ErrorCF("channels", "HTTP server goroutine panic recovered",
map[string]any{
"addr": ln.Addr().String(),
"panic": fmt.Sprintf("%v", r),
"stack": string(debug.Stack()),
})
}
}()
logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{
"addr": ln.Addr().String(),
})
@@ -1282,6 +1332,16 @@ func (m *Manager) StartAll(ctx context.Context) error {
}
} else {
go func() {
defer func() {
if r := recover(); r != nil {
logger.ErrorCF("channels", "HTTP server goroutine panic recovered",
map[string]any{
"addr": m.httpServer.Addr,
"panic": fmt.Sprintf("%v", r),
"stack": string(debug.Stack()),
})
}
}()
logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{
"addr": m.httpServer.Addr,
})
@@ -1933,6 +1993,15 @@ func (m *Manager) Reload(ctx context.Context, cfg *config.Config) error {
// Commit hashes only on full success.
m.channelHashes = list
go func() {
defer func() {
if r := recover(); r != nil {
logger.ErrorCF("channels", "channel registration goroutine panic recovered",
map[string]any{
"panic": fmt.Sprintf("%v", r),
"stack": string(debug.Stack()),
})
}
}()
for _, f := range deferFuncs {
f()
}
+25 -6
View File
@@ -4,6 +4,7 @@ import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"log"
"github.com/sipeed/picoclaw/pkg/config"
)
@@ -11,17 +12,27 @@ import (
func toChannelHashes(cfg *config.Config) map[string]string {
result := make(map[string]string)
ch := cfg.Channels
// should not be error
marshal, _ := json.Marshal(ch)
marshal, err := json.Marshal(ch)
if err != nil {
log.Printf("[manager_channel] failed to marshal channels config: %v", err)
return result
}
var channelConfig map[string]map[string]any
_ = json.Unmarshal(marshal, &channelConfig)
if err := json.Unmarshal(marshal, &channelConfig); err != nil {
log.Printf("[manager_channel] failed to unmarshal channels config: %v", err)
return result
}
for key, value := range channelConfig {
if !value["enabled"].(bool) {
if enabled, ok := value["enabled"].(bool); !ok || !enabled {
continue
}
hiddenValues(key, value, ch.Get(key))
valueBytes, _ := json.Marshal(value)
valueBytes, err := json.Marshal(value)
if err != nil {
log.Printf("[manager_channel] failed to marshal channel %s config: %v", key, err)
continue
}
hash := md5.Sum(valueBytes)
result[key] = hex.EncodeToString(hash[:])
}
@@ -94,7 +105,15 @@ func hiddenValues(key string, value map[string]any, ch *config.Channel) {
vv := value["webhooks"]
webhooks := make(map[string]string)
if vv != nil {
webhooks = vv.(map[string]string)
if m, ok := vv.(map[string]string); ok {
webhooks = m
} else if m, ok := vv.(map[string]any); ok {
for k, w := range m {
if s, ok := w.(string); ok {
webhooks[k] = s
}
}
}
}
if settings, ok := v.(*config.TeamsWebhookSettings); ok {
for name, target := range settings.Webhooks {
+54
View File
@@ -151,3 +151,57 @@ func TestToChannelHashes_RealWorldChannel(t *testing.T) {
assert.Equal(t, 1, len(h))
assert.Contains(t, h, "telegram")
}
func TestToChannelHashes_MissingEnabledKey(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Channels["test"] = &config.Channel{
Settings: config.RawNode(`{"key":"value"}`),
}
// Should not panic — the ok check safely handles the missing/false case
assert.NotPanics(t, func() {
_ = toChannelHashes(cfg)
})
h := toChannelHashes(cfg)
assert.Equal(t, 0, len(h), "channel with Enabled=false (default) skipped")
}
func TestToChannelHashes_EnabledNotBool(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Channels["test"] = &config.Channel{
Enabled: false,
Settings: config.RawNode(`{"enabled":"yes","boolField":true}`),
}
// Should not panic — string "enabled" won't match bool assertion, ok=false
assert.NotPanics(t, func() {
_ = toChannelHashes(cfg)
})
h := toChannelHashes(cfg)
assert.Equal(t, 0, len(h), "string enabled not treated as true")
}
func TestToChannelHashes_TeamsWebhookWithWebhooks(t *testing.T) {
cfg := config.DefaultConfig()
// teams_webhook with configured webhooks — this is the real-world
// scenario where the map type from JSON unmarshal (map[string]any)
// would cause a panic on the old unchecked vv.(map[string]string)
settings, _ := json.Marshal(map[string]any{
"enabled": true,
"webhooks": map[string]any{
"hook1": "https://example.com/webhook",
},
})
cfg.Channels["teams_webhook"] = &config.Channel{
Enabled: true,
Type: config.ChannelTeamsWebHook,
Settings: config.RawNode(settings),
}
assert.NotPanics(t, func() {
_ = toChannelHashes(cfg)
})
h := toChannelHashes(cfg)
assert.Equal(t, 1, len(h))
assert.Contains(t, h, "teams_webhook")
}
+53
View File
@@ -3383,3 +3383,56 @@ func TestManager_SendPlaceholder(t *testing.T) {
t.Error("expected SendPlaceholder to fail for unknown channel")
}
}
// turnUsageTrackingStreamer is a mockStreamer that records SetTurnUsage calls,
// used to verify the manager's streamer wrappers forward per-turn token usage
// to the inner streamer (regression: the wrappers previously dropped it because
// SetTurnUsage is not part of the bus.Streamer interface).
type turnUsageTrackingStreamer struct {
mockStreamer
inputTokens int
outputTokens int
usageCalls int
}
func (m *turnUsageTrackingStreamer) SetTurnUsage(inputTokens, outputTokens int) {
m.usageCalls++
m.inputTokens = inputTokens
m.outputTokens = outputTokens
}
func TestFinalizeHookStreamerForwardsTurnUsage(t *testing.T) {
inner := &turnUsageTrackingStreamer{}
wrapper := &finalizeHookStreamer{Streamer: inner}
setter, ok := any(wrapper).(turnUsageStreamer)
if !ok {
t.Fatal("finalizeHookStreamer does not satisfy turnUsageStreamer")
}
setter.SetTurnUsage(1234, 567)
if inner.usageCalls != 1 {
t.Fatalf("inner SetTurnUsage calls = %d, want 1", inner.usageCalls)
}
if inner.inputTokens != 1234 || inner.outputTokens != 567 {
t.Errorf("inner usage = (%d, %d), want (1234, 567)", inner.inputTokens, inner.outputTokens)
}
}
func TestSplitMarkerStreamerForwardsTurnUsage(t *testing.T) {
inner := &turnUsageTrackingStreamer{}
wrapper := &splitMarkerStreamer{current: inner}
setter, ok := any(wrapper).(turnUsageStreamer)
if !ok {
t.Fatal("splitMarkerStreamer does not satisfy turnUsageStreamer")
}
setter.SetTurnUsage(1234, 567)
if inner.usageCalls != 1 {
t.Fatalf("inner SetTurnUsage calls = %d, want 1", inner.usageCalls)
}
if inner.inputTokens != 1234 || inner.outputTokens != 567 {
t.Errorf("inner usage = (%d, %d), want (1234, 567)", inner.inputTokens, inner.outputTokens)
}
}
+15 -11
View File
@@ -24,6 +24,7 @@ import (
type OneBotChannel struct {
*channels.BaseChannel
config *config.OneBotSettings
downloadFn func(urlStr, filename string) string
conn *websocket.Conn
ctx context.Context
cancel context.CancelFunc
@@ -194,7 +195,7 @@ func (c *OneBotChannel) connect() error {
conn, resp, err := dialer.Dial(c.config.WSUrl, header)
if resp != nil {
resp.Body.Close()
_ = resp.Body.Close()
}
if err != nil {
return err
@@ -795,9 +796,7 @@ func (c *OneBotChannel) parseMessageSegments(
} else if n, ok := data["name"].(string); ok && n != "" {
filename = n
}
localPath := utils.DownloadFile(url, filename, utils.DownloadOptions{
LoggerPrefix: "onebot",
})
localPath := c.downloadInboundFile(url, filename)
if localPath != "" {
mediaRefs = append(mediaRefs, storeFile(localPath, filename))
textParts = append(textParts, fmt.Sprintf("[%s]", segType))
@@ -809,9 +808,7 @@ func (c *OneBotChannel) parseMessageSegments(
if data != nil {
url, _ := data["url"].(string)
if url != "" {
localPath := utils.DownloadFile(url, "voice.amr", utils.DownloadOptions{
LoggerPrefix: "onebot",
})
localPath := c.downloadInboundFile(url, "voice.amr")
if localPath != "" {
textParts = append(textParts, "[voice]")
mediaRefs = append(mediaRefs, storeFile(localPath, "voice.amr"))
@@ -847,6 +844,16 @@ func (c *OneBotChannel) parseMessageSegments(
}
}
func (c *OneBotChannel) downloadInboundFile(urlStr, filename string) string {
if c.downloadFn != nil {
return c.downloadFn(urlStr, filename)
}
return utils.DownloadFile(urlStr, filename, utils.DownloadOptions{
LoggerPrefix: "onebot",
BlockPrivateTargets: true,
})
}
func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
switch raw.PostType {
case "message":
@@ -995,7 +1002,6 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
senderID := strconv.FormatInt(userID, 10)
var chatID string
var contextChatID string
var contextChatType string
metadata := map[string]string{}
@@ -1007,13 +1013,11 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
switch raw.MessageType {
case "private":
chatID = "private:" + senderID
contextChatID = senderID
contextChatType = "direct"
case "group":
groupIDStr := strconv.FormatInt(groupID, 10)
chatID = "group:" + groupIDStr
contextChatID = groupIDStr
contextChatType = "group"
metadata["group_id"] = groupIDStr
@@ -1080,7 +1084,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
inboundCtx := bus.InboundContext{
Channel: c.Name(),
ChatID: contextChatID,
ChatID: chatID,
ChatType: contextChatType,
SenderID: senderID,
MessageID: messageID,
+126
View File
@@ -0,0 +1,126 @@
package onebot
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/media"
)
func TestParseMessageSegments_BlocksLoopbackInboundMediaURL(t *testing.T) {
ch := &OneBotChannel{}
store := media.NewFileMediaStore()
raw := json.RawMessage(`[
{"type":"text","data":{"text":"see attachment"}},
{"type":"image","data":{"url":"http://127.0.0.1:8080/evil.png","file":"evil.png"}}
]`)
result := ch.parseMessageSegments(raw, 0, store, "onebot:test:msg1")
if got := result.Text; got != "see attachment" {
t.Fatalf("Text = %q, want %q", got, "see attachment")
}
if len(result.Media) != 0 {
t.Fatalf("Media count = %d, want 0", len(result.Media))
}
}
func TestParseMessageSegments_BlocksInboundMediaRedirectToLoopback(t *testing.T) {
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("secret"))
}))
defer target.Close()
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got, want := r.URL.String(), "http://example.com/evil.png"; got != want {
t.Fatalf("proxy request URL = %q, want %q", got, want)
}
http.Redirect(w, r, target.URL, http.StatusFound)
}))
defer proxy.Close()
t.Setenv("HTTP_PROXY", proxy.URL)
t.Setenv("http_proxy", proxy.URL)
t.Setenv("HTTPS_PROXY", "")
t.Setenv("https_proxy", "")
t.Setenv("ALL_PROXY", "")
t.Setenv("all_proxy", "")
t.Setenv("NO_PROXY", "")
t.Setenv("no_proxy", "")
ch := &OneBotChannel{}
store := media.NewFileMediaStore()
raw := json.RawMessage(`[
{"type":"text","data":{"text":"see attachment"}},
{"type":"image","data":{"url":"http://example.com/evil.png","file":"evil.png"}}
]`)
result := ch.parseMessageSegments(raw, 0, store, "onebot:test:msg-redirect")
if got := result.Text; got != "see attachment" {
t.Fatalf("Text = %q, want %q", got, "see attachment")
}
if len(result.Media) != 0 {
t.Fatalf("Media count = %d, want 0", len(result.Media))
}
}
func TestParseMessageSegments_StoresDownloadedMediaRef(t *testing.T) {
tmpDir := t.TempDir()
localPath := filepath.Join(tmpDir, "image.png")
if err := os.WriteFile(localPath, []byte("fake-image"), 0o600); err != nil {
t.Fatalf("os.WriteFile() error = %v", err)
}
ch := &OneBotChannel{
downloadFn: func(urlStr, filename string) string {
if urlStr != "https://cdn.example.com/image.png" {
t.Fatalf("download url = %q, want %q", urlStr, "https://cdn.example.com/image.png")
}
if filename != "image.png" {
t.Fatalf("download filename = %q, want %q", filename, "image.png")
}
return localPath
},
}
store := media.NewFileMediaStore()
raw := json.RawMessage(`[
{"type":"text","data":{"text":"see attachment"}},
{"type":"image","data":{"url":"https://cdn.example.com/image.png","file":"image.png"}}
]`)
result := ch.parseMessageSegments(raw, 0, store, "onebot:test:msg2")
if got := result.Text; got != "see attachment[image]" {
t.Fatalf("Text = %q, want %q", got, "see attachment[image]")
}
if len(result.Media) != 1 {
t.Fatalf("Media count = %d, want 1", len(result.Media))
}
if !strings.HasPrefix(result.Media[0], "media://") {
t.Fatalf("media ref = %q, want media:// prefix", result.Media[0])
}
resolvedPath, meta, err := store.ResolveWithMeta(result.Media[0])
if err != nil {
t.Fatalf("ResolveWithMeta() error = %v", err)
}
if resolvedPath != localPath {
t.Fatalf("resolved path = %q, want %q", resolvedPath, localPath)
}
if meta.Source != "onebot" {
t.Fatalf("meta.Source = %q, want %q", meta.Source, "onebot")
}
if meta.Filename != "image.png" {
t.Fatalf("meta.Filename = %q, want %q", meta.Filename, "image.png")
}
}
+1 -1
View File
@@ -88,7 +88,7 @@ func (c *PicoClientChannel) dial() error {
ws, resp, err := websocket.DefaultDialer.DialContext(c.ctx, c.config.URL, header)
if resp != nil && resp.Body != nil {
resp.Body.Close()
_ = resp.Body.Close()
}
if err != nil {
return err
+46 -5
View File
@@ -105,6 +105,8 @@ type PicoChannel struct {
cancel context.CancelFunc
progress *channels.ToolFeedbackAnimator
deleteMessageFn func(context.Context, string, string) error
// broadcastFn lets tests intercept outbound broadcasts. nil → broadcastToSession.
broadcastFn func(chatID string, msg PicoMessage) error
}
// NewPicoChannel creates a new Pico Protocol channel.
@@ -531,6 +533,8 @@ type picoStreamer struct {
channel *PicoChannel
chatID string
modelName string
turnInputTokens int
turnOutputTokens int
messageID string
reasoningID string
throttleInterval time.Duration
@@ -553,6 +557,17 @@ func (s *picoStreamer) SetModelName(modelName string) {
s.modelName = strings.TrimSpace(modelName)
}
// SetTurnUsage records the real per-turn LLM token usage to emit on finalize.
func (s *picoStreamer) SetTurnUsage(inputTokens, outputTokens int) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.turnInputTokens = inputTokens
s.turnOutputTokens = outputTokens
}
func (s *picoStreamer) Update(ctx context.Context, content string) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -661,8 +676,9 @@ func (s *picoStreamer) sendLocked(ctx context.Context, content string, contextUs
payload[PayloadKeyModelName] = s.modelName
}
setContextUsagePayload(payload, contextUsage)
setTurnUsagePayload(payload, s.turnInputTokens, s.turnOutputTokens)
outMsg := newMessage(TypeMessageCreate, payload)
if err := s.channel.broadcastToSession(s.chatID, outMsg); err != nil {
if err := s.channel.broadcast(s.chatID, outMsg); err != nil {
return err
}
} else if content != s.lastContent || contextUsage != nil {
@@ -673,6 +689,7 @@ func (s *picoStreamer) sendLocked(ctx context.Context, content string, contextUs
if s.modelName != "" {
payload[PayloadKeyModelName] = s.modelName
}
setTurnUsagePayload(payload, s.turnInputTokens, s.turnOutputTokens)
if err := s.channel.editMessagePayload(ctx, s.chatID, s.messageID, payload, contextUsage); err != nil {
return err
}
@@ -932,6 +949,14 @@ func (c *PicoChannel) handleMediaDownload(w http.ResponseWriter, r *http.Request
http.ServeContent(w, r, filename, info.ModTime(), file)
}
// broadcast routes through broadcastFn when set (tests), else broadcastToSession.
func (c *PicoChannel) broadcast(chatID string, msg PicoMessage) error {
if c.broadcastFn != nil {
return c.broadcastFn(chatID, msg)
}
return c.broadcastToSession(chatID, msg)
}
// broadcastToSession sends a message to all connections with a matching session.
func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error {
// chatID format: "pico:<sessionID>"
@@ -1394,10 +1419,26 @@ func setContextUsagePayload(payload map[string]any, u *bus.ContextUsage) {
return
}
payload["context_usage"] = map[string]any{
"used_tokens": u.UsedTokens,
"total_tokens": u.TotalTokens,
"compress_at_tokens": u.CompressAtTokens,
"used_percent": u.UsedPercent,
"used_tokens": u.UsedTokens,
"total_tokens": u.TotalTokens,
"history_tokens": u.HistoryTokens,
"compress_at_tokens": u.CompressAtTokens,
"summarize_at_tokens": u.SummarizeAtTokens,
"used_percent": u.UsedPercent,
}
}
// setTurnUsagePayload attaches real per-turn LLM token usage to the payload.
// Input and output are kept separate (billed at different rates); total is a
// convenience sum. Omitted entirely when both counts are zero.
func setTurnUsagePayload(payload map[string]any, inputTokens, outputTokens int) {
if inputTokens <= 0 && outputTokens <= 0 {
return
}
payload[PayloadKeyUsage] = map[string]any{
"input_tokens": inputTokens,
"output_tokens": outputTokens,
"total_tokens": inputTokens + outputTokens,
}
}
+81 -4
View File
@@ -602,10 +602,12 @@ func TestBeginStream_FinalizeIncludesContextUsage(t *testing.T) {
t.Fatal("streamer should support FinalizeWithContext")
}
if err := contextStreamer.FinalizeWithContext(context.Background(), "final", &bus.ContextUsage{
UsedTokens: 10,
TotalTokens: 100,
CompressAtTokens: 80,
UsedPercent: 10,
UsedTokens: 10,
TotalTokens: 100,
HistoryTokens: 5,
CompressAtTokens: 80,
SummarizeAtTokens: 60,
UsedPercent: 10,
}); err != nil {
t.Fatalf("FinalizeWithContext() error = %v", err)
}
@@ -627,6 +629,12 @@ func TestBeginStream_FinalizeIncludesContextUsage(t *testing.T) {
if got := rawUsage["used_tokens"]; got != float64(10) {
t.Fatalf("used_tokens = %#v, want 10", got)
}
if got := rawUsage["history_tokens"]; got != float64(5) {
t.Fatalf("history_tokens = %#v, want 5", got)
}
if got := rawUsage["summarize_at_tokens"]; got != float64(60) {
t.Fatalf("summarize_at_tokens = %#v, want 60", got)
}
}
func TestCreateAndAddConnection_RespectsMaxConnectionsConcurrently(t *testing.T) {
@@ -835,6 +843,75 @@ func TestSendMedia_DismissesTrackedToolFeedbackMessage(t *testing.T) {
}
}
func TestSendMedia_IncludesCaptionAndAttachmentsInSinglePayload(t *testing.T) {
ch := newTestPicoChannel(t)
store := media.NewFileMediaStore()
ch.SetMediaStore(store)
if err := ch.Start(context.Background()); err != nil {
t.Fatalf("Start() error = %v", err)
}
defer ch.Stop(context.Background())
clientConn, received, cleanup := newTestPicoWebSocket(t)
defer cleanup()
ch.addConnForTest(&picoConn{id: "conn-1", conn: clientConn, sessionID: "sess-1"})
localPath := filepath.Join(t.TempDir(), "photo.png")
if err := os.WriteFile(localPath, []byte("png-body"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
ref, err := store.Store(localPath, media.MediaMeta{
Filename: "photo.png",
ContentType: "image/png",
}, "test-scope")
if err != nil {
t.Fatalf("Store() error = %v", err)
}
_, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "pico:sess-1",
Parts: []bus.MediaPart{{
Ref: ref,
Type: "image",
Filename: "photo.png",
ContentType: "image/png",
Caption: "recipe translation",
}},
})
if err != nil {
t.Fatalf("SendMedia() error = %v", err)
}
select {
case msg := <-received:
if msg.Type != TypeMessageCreate {
t.Fatalf("message type = %q, want %q", msg.Type, TypeMessageCreate)
}
payload := msg.Payload
if got := payload[PayloadKeyContent]; got != "recipe translation" {
t.Fatalf("content = %#v, want %q", got, "recipe translation")
}
rawAttachments, ok := payload["attachments"].([]any)
if !ok || len(rawAttachments) != 1 {
t.Fatalf("attachments = %#v, want 1 attachment", payload["attachments"])
}
attachment, ok := rawAttachments[0].(map[string]any)
if !ok {
t.Fatalf("attachment = %#v, want map", rawAttachments[0])
}
if got := attachment["type"]; got != "image" {
t.Fatalf("attachment type = %#v, want image", got)
}
if got := attachment["filename"]; got != "photo.png" {
t.Fatalf("attachment filename = %#v, want photo.png", got)
}
case <-time.After(time.Second):
t.Fatal("expected media payload to be delivered")
}
}
func TestPicoDownloadURLForRef(t *testing.T) {
got, err := picoDownloadURLForRef("media://attachment-1")
if err != nil {
+69
View File
@@ -0,0 +1,69 @@
package pico
import (
"context"
"testing"
)
func TestSetTurnUsagePayload(t *testing.T) {
t.Run("populates usage block when counts present", func(t *testing.T) {
payload := map[string]any{PayloadKeyContent: "hi"}
setTurnUsagePayload(payload, 1234, 567)
raw, ok := payload[PayloadKeyUsage]
if !ok {
t.Fatalf("expected %q key in payload", PayloadKeyUsage)
}
usage, ok := raw.(map[string]any)
if !ok {
t.Fatalf("usage block is not a map: %T", raw)
}
if usage["input_tokens"] != 1234 {
t.Errorf("input_tokens = %v, want 1234", usage["input_tokens"])
}
if usage["output_tokens"] != 567 {
t.Errorf("output_tokens = %v, want 567", usage["output_tokens"])
}
if usage["total_tokens"] != 1801 {
t.Errorf("total_tokens = %v, want 1801", usage["total_tokens"])
}
})
t.Run("omits usage block when both counts zero", func(t *testing.T) {
payload := map[string]any{PayloadKeyContent: "hi"}
setTurnUsagePayload(payload, 0, 0)
if _, ok := payload[PayloadKeyUsage]; ok {
t.Errorf("expected no %q key when counts are zero", PayloadKeyUsage)
}
})
}
// newCaptureStreamer returns a streamer whose broadcasts are captured into the
// returned map pointer, so tests need no live websocket.
func newCaptureStreamer() (*picoStreamer, *map[string]any) {
var last map[string]any
ch := &PicoChannel{}
ch.broadcastFn = func(chatID string, msg PicoMessage) error {
last = msg.Payload
return nil
}
s := &picoStreamer{channel: ch, chatID: "c1"}
return s, &last
}
func TestStreamerEmitsUsageOnFinalize(t *testing.T) {
s, last := newCaptureStreamer()
s.SetTurnUsage(100, 40)
// sendLocked with empty messageID takes the create branch, which attaches
// usage from the streamer's stored counts.
s.mu.Lock()
err := s.sendLocked(context.Background(), "answer", nil)
s.mu.Unlock()
if err != nil {
t.Fatalf("sendLocked: %v", err)
}
if _, ok := (*last)[PayloadKeyUsage]; !ok {
t.Fatalf("expected usage in payload, got %+v", *last)
}
}
+1
View File
@@ -28,6 +28,7 @@ const (
PayloadKeyPlaceholder = "placeholder"
PayloadKeyToolCalls = "tool_calls"
PayloadKeyModelName = "model_name"
PayloadKeyUsage = "usage"
MessageKindThought = "thought"
MessageKindToolCalls = "tool_calls"

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