Compare commits

..

136 Commits

Author SHA1 Message Date
dependabot[bot] 8ffb8ae8d2 build(deps): bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-16 17:13:55 +00: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
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
Pierre LE GUEN 7de3d1f7e3 feat(provider): add NEAR AI Cloud provider 2026-06-12 10:18:34 +00: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
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
程智超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
115 changed files with 8346 additions and 1504 deletions
+2 -2
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Run Docker-backed integration suites - name: Run Docker-backed integration suites
run: bash ./scripts/run-integration-tests.sh run: bash ./scripts/run-integration-tests.sh
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
ref: main ref: main
+2 -2
View File
@@ -13,7 +13,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
ref: main ref: main
@@ -30,7 +30,7 @@ jobs:
run_install: false run_install: false
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
steps: steps:
# ── Checkout ────────────────────────────── # ── Checkout ──────────────────────────────
- name: 📥 Checkout repository - name: 📥 Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
ref: ${{ inputs.tag }} ref: ${{ inputs.tag }}
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
packages: write packages: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -54,7 +54,7 @@ jobs:
run_install: false run_install: false
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
+4 -4
View File
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
@@ -32,7 +32,7 @@ jobs:
GOFLAGS: -tags=goolm,stdjson GOFLAGS: -tags=goolm,stdjson
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
@@ -52,7 +52,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
@@ -70,7 +70,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Run Docker-backed integration suites - name: Run Docker-backed integration suites
run: bash ./scripts/run-integration-tests.sh run: bash ./scripts/run-integration-tests.sh
+2 -2
View File
@@ -42,7 +42,7 @@ jobs:
fi fi
- name: Checkout tag - name: Checkout tag
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ inputs.tag }} ref: ${{ inputs.tag }}
@@ -60,7 +60,7 @@ jobs:
run_install: false run_install: false
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
+17 -2
View File
@@ -20,6 +20,17 @@
[中文](docs/project/README.zh.md) | [日本語](docs/project/README.ja.md) | [한국어](docs/project/README.ko.md) | [Português](docs/project/README.pt-br.md) | [Tiếng Việt](docs/project/README.vi.md) | [Français](docs/project/README.fr.md) | [Italiano](docs/project/README.it.md) | [Bahasa Indonesia](docs/project/README.id.md) | [Malay](docs/project/README.ms.md) | **English** [中文](docs/project/README.zh.md) | [日本語](docs/project/README.ja.md) | [한국어](docs/project/README.ko.md) | [Português](docs/project/README.pt-br.md) | [Tiếng Việt](docs/project/README.vi.md) | [Français](docs/project/README.fr.md) | [Italiano](docs/project/README.it.md) | [Bahasa Indonesia](docs/project/README.id.md) | [Malay](docs/project/README.ms.md) | **English**
<p>
<a href="https://picopaw.ai">
<img src="assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
</a>
</p>
<p>
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
</p>
</div> </div>
--- ---
@@ -49,13 +60,15 @@
> **Security Notice** > **Security Notice**
> >
> * **NO CRYPTO:** PicoClaw has **not** issued any official tokens or cryptocurrency. All claims on `pump.fun` or other trading platforms are **scams**. > * **NO CRYPTO:** PicoClaw has **not** issued any official tokens or cryptocurrency. All claims on `pump.fun` or other trading platforms are **scams**.
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)** > * **OFFICIAL DOMAIN:** The **ONLY** official PicoClaw website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
> * **BEWARE:** Many `.ai/.org/.com/.net/...` domains have been registered by third parties. Do not trust them. > * **BEWARE:** Many lookalike `.ai/.org/.com/.net/...` domains have been registered by third parties. Only trust domains explicitly linked from this README.
> * **NOTE:** PicoClaw is in early rapid development. There may be unresolved security issues. Do not deploy to production before v1.0. > * **NOTE:** PicoClaw is in early rapid development. There may be unresolved security issues. Do not deploy to production before v1.0.
> * **NOTE:** PicoClaw has recently merged many PRs. Recent builds may use 10-20MB RAM. Resource optimization is planned after feature stabilization. > * **NOTE:** PicoClaw has recently merged many PRs. Recent builds may use 10-20MB RAM. Resource optimization is planned after feature stabilization.
## 📢 News ## 📢 News
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
2026-05-11 🛒 **LicheeRV-Claw on AliExpress!** You can now purchase LicheeRV-Claw from [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), making it easier to try PicoClaw on compact RISC-V hardware. 2026-05-11 🛒 **LicheeRV-Claw on AliExpress!** You can now purchase LicheeRV-Claw from [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), making it easier to try PicoClaw on compact RISC-V hardware.
<p align="center"> <p align="center">
@@ -420,6 +433,7 @@ PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Required | Mistral Large, Codestral | | [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Required | Mistral Large, Codestral |
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Required | NVIDIA hosted models | | [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Required | NVIDIA hosted models |
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Required | Fast inference | | [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 | | [Novita AI](https://novita.ai/) | `novita/` | Required | Various open models |
| [Xiaomi MiMo](https://platform.xiaomimimo.com/) | `mimo/` | Required | MiMo models | | [Xiaomi MiMo](https://platform.xiaomimimo.com/) | `mimo/` | Required | MiMo models |
| [Ollama](https://ollama.com/) | `ollama/` | Not needed | Local models, self-hosted | | [Ollama](https://ollama.com/) | `ollama/` | Not needed | Local models, self-hosted |
@@ -480,6 +494,7 @@ Talk to your PicoClaw through 19+ messaging platforms:
| **QQ** | Easy (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.md) | | **QQ** | Easy (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.md) |
| **Slack** | Easy (bot + app token) | Socket Mode | [Guide](docs/channels/slack/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) | | **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) | | **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) | | **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) | | **LINE** | Medium (credentials + webhook) | Webhook | [Guide](docs/channels/line/README.md) |
Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 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) respBody, lastErr = io.ReadAll(resp.Body)
resp.Body.Close() _ = resp.Body.Close()
if lastErr != nil { if lastErr != nil {
continue continue
} }
+16
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv"
"strings" "strings"
"testing" "testing"
@@ -68,6 +69,21 @@ func TestFetchOpenAIModels_HTTPError(t *testing.T) {
assert.Contains(t, err.Error(), "HTTP 401") 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) { func TestFetchOpenAIModels_EmptyDataEnvelope(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"data":[]}`)) _, _ = w.Write([]byte(`{"data":[]}`))
+4 -1
View File
@@ -45,7 +45,10 @@ func fetchOpenAIModels(baseURL, apiKey string) ([]modelEntry, error) {
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { 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))) return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
} }
+5
View File
@@ -302,6 +302,11 @@ func skillsSearchCmd(query string) {
if result.Version != "" { if result.Version != "" {
fmt.Printf(" Version: %s\n", 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() fmt.Println()
} }
} }
+8 -1
View File
@@ -67,6 +67,11 @@
"model": "venice/venice-uncensored", "model": "venice/venice-uncensored",
"api_keys": ["your-venice-api-key"] "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_name": "lmstudio-local",
"model": "lmstudio/openai/gpt-oss-20b" "model": "lmstudio/openai/gpt-oss-20b"
@@ -380,7 +385,9 @@
}, },
"cron": { "cron": {
"enabled": true, "enabled": true,
"exec_timeout_minutes": 5 "exec_timeout_minutes": 5,
"allow_command": true,
"command_allowed_remotes": []
}, },
"mcp": { "mcp": {
"enabled": false, "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 |
+5 -1
View File
@@ -1154,7 +1154,9 @@ PicoClaw supports cron-style scheduled tasks via the `cron` tool. The agent can
"tools": { "tools": {
"cron": { "cron": {
"enabled": true, "enabled": true,
"exec_timeout_minutes": 5 "exec_timeout_minutes": 5,
"allow_command": true,
"command_allowed_remotes": []
} }
} }
} }
@@ -1162,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/`. 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 ### Advanced Topics
| Topic | Description | | Topic | Description |
+16 -1
View File
@@ -17,6 +17,7 @@
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | | `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | | `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
| `venice` | LLM (Venice AI direct) | [venice.ai](https://venice.ai) | | `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) | | `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.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) | | `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) | | **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) | | **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) | | **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) | | **智谱 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) | | **Z.AI Coding Plan** | `openai` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) |
@@ -256,6 +258,17 @@ Notes that matter:
} }
``` ```
**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)** **VolcEngine (Doubao)**
```json ```json
@@ -647,7 +660,9 @@ picoclaw agent -m "Hello"
} }
}, },
"cron": { "cron": {
"exec_timeout_minutes": 5 "exec_timeout_minutes": 5,
"allow_command": true,
"command_allowed_remotes": []
} }
}, },
"heartbeat": { "heartbeat": {
+13
View File
@@ -20,6 +20,17 @@
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md) [中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
<p>
<a href="https://picopaw.ai">
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
</a>
</p>
<p>
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
</p>
</div> </div>
--- ---
@@ -57,6 +68,8 @@
## 📢 Actualités ## 📢 Actualités
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
2026-05-11 🛒 **LicheeRV-Claw disponible sur AliExpress !** Vous pouvez désormais acheter le LicheeRV-Claw sur [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), ce qui facilite l'essai de PicoClaw sur du matériel RISC-V compact. 2026-05-11 🛒 **LicheeRV-Claw disponible sur AliExpress !** Vous pouvez désormais acheter le LicheeRV-Claw sur [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), ce qui facilite l'essai de PicoClaw sur du matériel RISC-V compact.
<p align="center"> <p align="center">
+13
View File
@@ -20,6 +20,17 @@
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | **Bahasa Indonesia** | [Malay](README.ms.md) | [English](../../README.md) [中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | **Bahasa Indonesia** | [Malay](README.ms.md) | [English](../../README.md)
<p>
<a href="https://picopaw.ai">
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
</a>
</p>
<p>
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
</p>
</div> </div>
--- ---
@@ -56,6 +67,8 @@
## 📢 Berita ## 📢 Berita
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
2026-05-11 🛒 **LicheeRV-Claw tersedia di AliExpress!** Kini Anda dapat membeli LicheeRV-Claw di [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), sehingga lebih mudah mencoba PicoClaw di hardware RISC-V ringkas. 2026-05-11 🛒 **LicheeRV-Claw tersedia di AliExpress!** Kini Anda dapat membeli LicheeRV-Claw di [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), sehingga lebih mudah mencoba PicoClaw di hardware RISC-V ringkas.
<p align="center"> <p align="center">
+13
View File
@@ -20,6 +20,17 @@
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md) [中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
<p>
<a href="https://picopaw.ai">
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
</a>
</p>
<p>
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
</p>
</div> </div>
--- ---
@@ -56,6 +67,8 @@
## 📢 Novità ## 📢 Novità
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
2026-05-11 🛒 **LicheeRV-Claw disponibile su AliExpress!** Ora puoi acquistare LicheeRV-Claw su [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), rendendo più semplice provare PicoClaw su hardware RISC-V compatto. 2026-05-11 🛒 **LicheeRV-Claw disponibile su AliExpress!** Ora puoi acquistare LicheeRV-Claw su [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), rendendo più semplice provare PicoClaw su hardware RISC-V compatto.
<p align="center"> <p align="center">
+13
View File
@@ -20,6 +20,17 @@
[中文](README.zh.md) | **日本語** | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md) [中文](README.zh.md) | **日本語** | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
<p>
<a href="https://picopaw.ai">
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
</a>
</p>
<p>
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
</p>
</div> </div>
--- ---
@@ -56,6 +67,8 @@
## 📢 ニュース ## 📢 ニュース
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
2026-05-11 🛒 **LicheeRV-Claw が AliExpress で購入可能に!** [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) から LicheeRV-Claw を購入できるようになり、コンパクトな RISC-V ハードウェアで PicoClaw を試しやすくなりました。 2026-05-11 🛒 **LicheeRV-Claw が AliExpress で購入可能に!** [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) から LicheeRV-Claw を購入できるようになり、コンパクトな RISC-V ハードウェアで PicoClaw を試しやすくなりました。
<p align="center"> <p align="center">
+13
View File
@@ -20,6 +20,17 @@
[中文](README.zh.md) | [日本語](README.ja.md) | **한국어** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md) [中文](README.zh.md) | [日本語](README.ja.md) | **한국어** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
<p>
<a href="https://picopaw.ai">
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
</a>
</p>
<p>
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
</p>
</div> </div>
--- ---
@@ -56,6 +67,8 @@
## 📢 뉴스 ## 📢 뉴스
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
2026-05-11 🛒 **LicheeRV-Claw를 AliExpress에서 구매할 수 있습니다!** 이제 [AliExpress](https://www.aliexpress.com/item/1005006519668532.html)에서 LicheeRV-Claw를 구매해 소형 RISC-V 하드웨어에서 PicoClaw를 더 쉽게 사용해 볼 수 있습니다. 2026-05-11 🛒 **LicheeRV-Claw를 AliExpress에서 구매할 수 있습니다!** 이제 [AliExpress](https://www.aliexpress.com/item/1005006519668532.html)에서 LicheeRV-Claw를 구매해 소형 RISC-V 하드웨어에서 PicoClaw를 더 쉽게 사용해 볼 수 있습니다.
<p align="center"> <p align="center">
+13
View File
@@ -20,6 +20,17 @@
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **Malay** | [English](../../README.md) [中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **Malay** | [English](../../README.md)
<p>
<a href="https://picopaw.ai">
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
</a>
</p>
<p>
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
</p>
</div> </div>
--- ---
@@ -56,6 +67,8 @@
## 📢 Berita ## 📢 Berita
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
2026-05-11 🛒 **LicheeRV-Claw tersedia di AliExpress!** Anda kini boleh membeli LicheeRV-Claw di [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), menjadikannya lebih mudah untuk mencuba PicoClaw pada perkakasan RISC-V yang kompak. 2026-05-11 🛒 **LicheeRV-Claw tersedia di AliExpress!** Anda kini boleh membeli LicheeRV-Claw di [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), menjadikannya lebih mudah untuk mencuba PicoClaw pada perkakasan RISC-V yang kompak.
<p align="center"> <p align="center">
+13
View File
@@ -20,6 +20,17 @@
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md) [中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
<p>
<a href="https://picopaw.ai">
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
</a>
</p>
<p>
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
</p>
</div> </div>
--- ---
@@ -56,6 +67,8 @@
## 📢 Novidades ## 📢 Novidades
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
2026-05-11 🛒 **LicheeRV-Claw no AliExpress!** Agora você pode comprar o LicheeRV-Claw no [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), facilitando testar o PicoClaw em hardware RISC-V compacto. 2026-05-11 🛒 **LicheeRV-Claw no AliExpress!** Agora você pode comprar o LicheeRV-Claw no [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), facilitando testar o PicoClaw em hardware RISC-V compacto.
<p align="center"> <p align="center">
+13
View File
@@ -20,6 +20,17 @@
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md) [中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
<p>
<a href="https://picopaw.ai">
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
</a>
</p>
<p>
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
</p>
</div> </div>
--- ---
@@ -56,6 +67,8 @@
## 📢 Tin tức ## 📢 Tin tức
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
2026-05-11 🛒 **LicheeRV-Claw đã có trên AliExpress!** Bạn hiện có thể mua LicheeRV-Claw trên [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), giúp việc thử PicoClaw trên phần cứng RISC-V nhỏ gọn dễ dàng hơn. 2026-05-11 🛒 **LicheeRV-Claw đã có trên AliExpress!** Bạn hiện có thể mua LicheeRV-Claw trên [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), giúp việc thử PicoClaw trên phần cứng RISC-V nhỏ gọn dễ dàng hơn.
<p align="center"> <p align="center">
+15 -2
View File
@@ -20,6 +20,17 @@
**中文** | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md) **中文** | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
<p>
<a href="https://picopaw.ai/zh">
<img src="../../assets/picopaw-banner-zh.webp" alt="PicoPaw AI:你的 AI 桌面伙伴" width="100%">
</a>
</p>
<p>
<strong>PicoPaw AI 已在 <a href="https://picopaw.ai/zh">picopaw.ai/zh</a> 上线。</strong><br>
创建、预览并分享面向 PicoClaw 生态的 AI 桌面伙伴。
</p>
</div> </div>
--- ---
@@ -49,13 +60,15 @@
> **🚨 安全声明** > **🚨 安全声明**
> >
> - **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗** > - **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**
> - **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。 > - **官方域名:** 唯一的 PicoClaw 官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
> - **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀域名被第三方抢注,请勿轻信 > - **警惕:** 许多相似的 `.ai/.org/.com/.net/...` 后缀域名被第三方抢注。请只信任本 README 明确链接的域名
> - **注意:** PicoClaw 正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在 1.0 正式版发布前,请不要将其部署到生产环境中。 > - **注意:** PicoClaw 正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在 1.0 正式版发布前,请不要将其部署到生产环境中。
> - **注意:** PicoClaw 最近合并了大量 PR,近期版本可能内存占用较大 (10~20MB),我们将在功能较为收敛后进行资源占用优化。 > - **注意:** PicoClaw 最近合并了大量 PR,近期版本可能内存占用较大 (10~20MB),我们将在功能较为收敛后进行资源占用优化。
## 📢 新闻 ## 📢 新闻
2026-06-11 🐾 **PicoPaw AI 上线!** 访问 [picopaw.ai/zh](https://picopaw.ai/zh),体验全新的 PicoPaw 桌面伙伴,预览会动的 AI 宠物,并关注 PicoClaw 生态更新。
2026-05-11 🛒 **LicheeRV-Claw 已上架淘宝!** 现在可以在 [淘宝](https://item.taobao.com/item.htm?abbucket=20&id=764939520376) 购买 LicheeRV-Claw,更方便地在小型 RISC-V 硬件上体验 PicoClaw。 2026-05-11 🛒 **LicheeRV-Claw 已上架淘宝!** 现在可以在 [淘宝](https://item.taobao.com/item.htm?abbucket=20&id=764939520376) 购买 LicheeRV-Claw,更方便地在小型 RISC-V 硬件上体验 PicoClaw。
<p align="center"> <p align="center">
+25 -5
View File
@@ -43,7 +43,8 @@ the original prompt, delivery target, or command payload.
Remote channel access is scoped to the current `channel/chat_id`: remote callers 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` 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 match the current conversation. Command jobs include a shell command payload, so
they can only be listed, inspected, or updated from internal channels. they can only be listed, inspected, or updated from internal channels or remote
channels allowed by `tools.cron.command_allowed_remotes`.
Example tool calls: Example tool calls:
@@ -59,7 +60,7 @@ Example tool calls:
(`at_seconds`, `every_seconds`, or `cron_expr`). (`at_seconds`, `every_seconds`, or `cron_expr`).
Omit `command` to preserve it, set `command` to a non-empty string to replace 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 it, or set `command` to `""` to clear it. Command updates require the same
internal channel and confirmation gates as command creation. channel allowlist and confirmation gates as command creation.
## Execution Modes ## Execution Modes
@@ -104,7 +105,7 @@ If `tools.exec.enabled` is `false`:
- new command jobs are rejected by the cron tool - new command jobs are rejected by the cron tool
- existing command jobs publish a `command execution is disabled` error when they fire - 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` ### `allow_command`
@@ -112,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`. 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: Example:
@@ -122,7 +139,10 @@ Example:
"cron": { "cron": {
"enabled": true, "enabled": true,
"exec_timeout_minutes": 5, "exec_timeout_minutes": 5,
"allow_command": true "allow_command": true,
"command_allowed_remotes": [
"telegram:1234567890"
]
}, },
"exec": { "exec": {
"enabled": true "enabled": true
+6 -5
View File
@@ -311,11 +311,12 @@ as containers, VMs, or an approval flow around build-and-run commands.
The cron tool is used for scheduling periodic tasks. The cron tool is used for scheduling periodic tasks.
| Config | Type | Default | Description | | Config | Type | Default | Description |
|------------------------|------|---------|------------------------------------------------| |---------------------------|----------|---------|----------------------------------------------------------------|
| `enabled` | bool | true | Register the agent-facing cron tool | | `enabled` | bool | true | Register the agent-facing cron tool |
| `allow_command` | bool | true | Allow command jobs without extra confirmation | | `allow_command` | bool | true | Allow command jobs without extra confirmation |
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit | | `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). 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).
+17 -17
View File
@@ -3,12 +3,12 @@ module github.com/sipeed/picoclaw
go 1.25.11 go 1.25.11
require ( require (
fyne.io/systray v1.12.1 fyne.io/systray v1.12.2
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0
github.com/SevereCloud/vksdk/v3 v3.3.1 github.com/SevereCloud/vksdk/v3 v3.3.1
github.com/adhocore/gronx v1.20.0 github.com/adhocore/gronx v1.20.0
github.com/anthropics/anthropic-sdk-go v1.46.0 github.com/anthropics/anthropic-sdk-go v1.55.1
github.com/atc0005/go-teams-notify/v2 v2.14.0 github.com/atc0005/go-teams-notify/v2 v2.14.0
github.com/aws/aws-sdk-go-v2 v1.42.0 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/config v1.32.25
@@ -26,12 +26,12 @@ require (
github.com/h2non/filetype v1.1.3 github.com/h2non/filetype v1.1.3
github.com/kagisearch/kagi-openapi-golang v0.0.0-20260526215348-96575e864d62 github.com/kagisearch/kagi-openapi-golang v0.0.0-20260526215348-96575e864d62
github.com/larksuite/oapi-sdk-go/v3 v3.9.4 github.com/larksuite/oapi-sdk-go/v3 v3.9.4
github.com/line/line-bot-sdk-go/v8 v8.20.0 github.com/line/line-bot-sdk-go/v8 v8.20.1
github.com/mdp/qrterminal/v3 v3.2.1 github.com/mdp/qrterminal/v3 v3.2.1
github.com/minio/selfupdate v0.6.0 github.com/minio/selfupdate v0.6.0
github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/muesli/termenv v0.16.0 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/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0 github.com/openai/openai-go/v3 v3.22.0
github.com/pion/rtp v1.10.2 github.com/pion/rtp v1.10.2
@@ -46,12 +46,12 @@ require (
go.mau.fi/util v0.9.8 go.mau.fi/util v0.9.8
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
golang.org/x/oauth2 v0.36.0 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 golang.org/x/time v0.15.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.27.0 maunium.net/go/mautrix v0.27.0
modernc.org/sqlite v1.51.0 modernc.org/sqlite v1.53.0
rsc.io/qr v0.2.0 rsc.io/qr v0.2.0
) )
@@ -59,7 +59,7 @@ require (
aead.dev/minisign v0.2.0 // indirect aead.dev/minisign v0.2.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.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/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/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/feature/ec2/imds v1.18.29 // indirect
@@ -91,15 +91,15 @@ require (
github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect github.com/invopop/jsonschema v0.14.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.42 // indirect github.com/mattn/go-sqlite3 v1.14.42 // indirect
github.com/ncruces/go-strftime v1.0.0 // 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/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
github.com/pion/randutil v0.1.0 // indirect github.com/pion/randutil v0.1.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
@@ -111,16 +111,16 @@ require (
github.com/vektah/gqlparser/v2 v2.5.27 // indirect github.com/vektah/gqlparser/v2 v2.5.27 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.mau.fi/libsignal v0.2.1 // indirect go.mau.fi/libsignal v0.2.1 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace 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/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.38.0 // indirect
modernc.org/libc v1.72.3 // indirect modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
) )
@@ -128,7 +128,7 @@ require (
require ( require (
github.com/andybalholm/brotli v1.2.1 // indirect github.com/andybalholm/brotli v1.2.1 // indirect
github.com/bytedance/gopkg v0.1.3 // 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/bytedance/sonic/loader v0.5.1 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/github/copilot-sdk/go v0.2.0 github.com/github/copilot-sdk/go v0.2.0
@@ -148,10 +148,10 @@ require (
github.com/valyala/fastjson v1.6.10 // indirect github.com/valyala/fastjson v1.6.10 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/arch v0.24.0 // indirect golang.org/x/arch v0.24.0 // indirect
golang.org/x/crypto v0.51.0 golang.org/x/crypto v0.53.0
golang.org/x/net v0.55.0 golang.org/x/net v0.55.0
golang.org/x/sync v0.21.0 golang.org/x/sync v0.21.0
golang.org/x/sys v0.45.0 golang.org/x/sys v0.46.0
) )
replace github.com/bwmarrin/discordgo => github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532 replace github.com/bwmarrin/discordgo => github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532
+46 -47
View File
@@ -3,20 +3,20 @@ aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= 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 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= 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.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= 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.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= 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.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= 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.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= 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.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= 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.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= 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 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= 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 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= 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.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= 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 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= 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 h1:O86zsp5LQnHE+O5acvuXM/s6S1LyxzVTkF6+Lup0Jyg=
@@ -29,8 +29,8 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNg
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= 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 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/anthropics/anthropic-sdk-go v1.46.0 h1:yl3n+el5ZfNgiCtQ7zQ7s/NXxB11YbrKXdc3uLPNWlU= github.com/anthropics/anthropic-sdk-go v1.55.1 h1:GxukHUVou6AFIngxa/Aw1z79hmwg13Hmn++KE9werbM=
github.com/anthropics/anthropic-sdk-go v1.46.0/go.mod h1:bx5vWuHFuGPkELH8Z4KUiNSohFnUwScdpTyr+50myPo= github.com/anthropics/anthropic-sdk-go v1.55.1/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 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZJED9nMLyscCo=
github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q= github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q=
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA= github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
@@ -75,8 +75,8 @@ github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJ
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= 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 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= 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.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= 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 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw=
@@ -180,9 +180,8 @@ 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/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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
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 h1:nyUi7Wel3KlVSa5ArgX/snlizqfaxU48qtvXS/JK5GE=
github.com/kagisearch/kagi-openapi-golang v0.0.0-20260526215348-96575e864d62/go.mod h1:vONkS+clG730HSKOw3nZVa22TjB21r6csKYzYt0a9zI= 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 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
@@ -206,12 +205,10 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 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 h1:oMgcY7NBjJv1QXJqFAfcoN/TbScCkCuRZfbb1mCwZmI=
github.com/larksuite/oapi-sdk-go/v3 v3.9.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= 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.0 h1:Jv22DV3JuQ5qZvniqUbg504bJrVzffXs2CMpyoiuIZU= github.com/line/line-bot-sdk-go/v8 v8.20.1 h1:OE7qwJbuZPz9MGOHHNI0E6rQNtIU7ENHyFXpvbVLwII=
github.com/line/line-bot-sdk-go/v8 v8.20.0/go.mod h1:QMXJwPka2ysSeVQKWXkBp8DzBFs+CFAXFNo75KJtWho= 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 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -228,8 +225,8 @@ github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/u
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= 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 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 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.10.0 h1:Upe0TqYyiK+yE5RFXXuQWVHGfLZnqvUfj4KZVjTcgWE=
github.com/mymmrac/telego v1.9.0/go.mod h1:tVEB7OqiOPx8elRk9+ETkwiDQrUhWSB2XmAKIY9KmWY= 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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
@@ -244,6 +241,8 @@ 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/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 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= 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 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= 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 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
@@ -324,8 +323,6 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
@@ -354,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 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= 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/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 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= 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= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -363,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-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.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.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= 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 h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= 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.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 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-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -422,16 +421,16 @@ 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.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.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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-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-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.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.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.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= 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.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= 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.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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -439,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.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.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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= 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= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -450,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.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.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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -483,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= 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 h1:yfEYwoIluVWkofUgbZl9gP4i5nQTF+QNsxtb+r5bKlM=
maunium.net/go/mautrix v0.27.0/go.mod h1:7QpEQiTy6p4LHkXXaZI+N46tGYy8HMhD0JjzZAFoFWs= 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.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= 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 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= 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 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 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.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= 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 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= 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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -505,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/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U= modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= 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 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+9
View File
@@ -333,6 +333,15 @@ func (al *AgentLoop) buildCommandsRuntime(
if opts == nil { if opts == nil {
return fmt.Errorf("process options not available") 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) return al.contextManager.Clear(ctx, opts.SessionKey)
} }
+18 -5
View File
@@ -100,10 +100,15 @@ func resolveMediaRefs(
localPath, meta, err := store.ResolveWithMeta(ref) localPath, meta, err := store.ResolveWithMeta(ref)
if err != nil { if err != nil {
logger.WarnCF("agent", "Failed to resolve media ref", map[string]any{ fields := map[string]any{
"ref": ref, "ref": ref,
"error": err.Error(), "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 continue
} }
@@ -172,14 +177,22 @@ func encodeImageToDataURL(localPath, mime string, info os.FileInfo, maxSize int)
buf.WriteString(prefix) buf.WriteString(prefix)
encoder := base64.NewEncoder(base64.StdEncoding, &buf) 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{ logger.WarnCF("agent", "Failed to encode media file", map[string]any{
"path": localPath, "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 "" return ""
} }
encoder.Close()
return buf.String() return buf.String()
} }
+1 -2
View File
@@ -6,7 +6,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"strings" "strings"
"time" "time"
@@ -21,7 +20,7 @@ func (al *AgentLoop) maybePublishError(ctx context.Context, channel, chatID, ses
if errors.Is(err, context.Canceled) { if errors.Is(err, context.Canceled) {
return false 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 return true
} }
+99
View File
@@ -3365,6 +3365,105 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) {
} }
} }
func TestProcessMessage_ClearCommandClearsRoutedAgentSession(t *testing.T) {
workspace := t.TempDir()
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: filepath.Join(workspace, "default"),
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
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(), &countingMockProvider{response: "LLM reply"})
mainAgent, ok := al.registry.GetAgent("main")
if !ok {
t.Fatal("expected main agent")
}
supportAgent, ok := al.registry.GetAgent("support")
if !ok {
t.Fatal("expected support agent")
}
msg := testInboundMessage(bus.InboundMessage{
Context: bus.InboundContext{
Channel: "dingtalk",
ChatID: "chat1",
ChatType: "direct",
SenderID: "user1",
},
Content: "/clear",
})
route, routedAgent, err := al.resolveMessageRoute(msg)
if err != nil {
t.Fatalf("resolveMessageRoute() error = %v", err)
}
if routedAgent != supportAgent {
t.Fatalf("routed agent = %s, want support", routedAgent.ID)
}
sessionKey := al.allocateRouteSession(route, msg).SessionKey
mainHistory := []providers.Message{{Role: "user", Content: "main history"}}
supportHistory := []providers.Message{{Role: "user", Content: "support history"}}
mainAgent.Sessions.SetHistory(sessionKey, mainHistory)
mainAgent.Sessions.SetSummary(sessionKey, "main summary")
supportAgent.Sessions.SetHistory(sessionKey, supportHistory)
supportAgent.Sessions.SetSummary(sessionKey, "support summary")
response, err := al.processMessage(context.Background(), msg)
if err != nil {
t.Fatalf("processMessage() error = %v", err)
}
if response != "Chat history cleared!" {
t.Fatalf("response = %q, want clear confirmation", response)
}
if got := supportAgent.Sessions.GetHistory(sessionKey); len(got) != 0 {
t.Fatalf("support history len = %d, want 0", len(got))
}
if got := supportAgent.Sessions.GetSummary(sessionKey); got != "" {
t.Fatalf("support summary = %q, want empty", got)
}
if got := mainAgent.Sessions.GetHistory(sessionKey); len(got) != len(mainHistory) {
t.Fatalf("main history len = %d, want %d", len(got), len(mainHistory))
} else if got[0].Role != mainHistory[0].Role {
t.Fatalf("main history[0].Role = %q, want %q", got[0].Role, mainHistory[0].Role)
} else if got[0].Content != mainHistory[0].Content {
t.Fatalf("main history[0].Content = %q, want %q", got[0].Content, mainHistory[0].Content)
}
if got := mainAgent.Sessions.GetSummary(sessionKey); got != "main summary" {
t.Fatalf("main summary = %q, want %q", got, "main summary")
}
}
func TestProcessMessage_MCPCommandsHandledWithoutLLMCall(t *testing.T) { func TestProcessMessage_MCPCommandsHandledWithoutLLMCall(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*") tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil { if err != nil {
+3 -1
View File
@@ -63,7 +63,9 @@ func (m *legacyContextManager) Ingest(_ context.Context, _ *IngestRequest) error
} }
func (m *legacyContextManager) Clear(_ context.Context, sessionKey string) 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 { if agent == nil || agent.Sessions == nil {
return fmt.Errorf("sessions not initialized") return fmt.Errorf("sessions not initialized")
} }
+95 -1
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"os" "os"
"path/filepath"
"strings" "strings"
"sync" "sync"
"sync/atomic" "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) // forceCompression edge cases (via legacy Compact)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -712,10 +798,12 @@ type trackingContextManager struct {
assembleCalls atomic.Int64 assembleCalls atomic.Int64
compactCalls atomic.Int64 compactCalls atomic.Int64
ingestCalls atomic.Int64 ingestCalls atomic.Int64
clearCalls atomic.Int64
mu sync.Mutex mu sync.Mutex
lastAssemble *AssembleRequest lastAssemble *AssembleRequest
lastCompact *CompactRequest lastCompact *CompactRequest
lastIngest *IngestRequest lastIngest *IngestRequest
lastClearKey string
} }
func (m *trackingContextManager) Assemble(_ context.Context, req *AssembleRequest) (*AssembleResponse, error) { 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 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 // resetCMRegistry clears the global factory registry and returns a cleanup
// function that restores the original state after the test. // function that restores the original state after the test.
+14 -4
View File
@@ -20,6 +20,7 @@ import (
type seahorseContextManager struct { type seahorseContextManager struct {
engine *seahorse.Engine engine *seahorse.Engine
sessions session.SessionStore // for startup bootstrap sessions session.SessionStore // for startup bootstrap
al *AgentLoop // for resolving the agent that owns a session
} }
// newSeahorseContextManager creates a seahorse-backed ContextManager. // newSeahorseContextManager creates a seahorse-backed ContextManager.
@@ -47,6 +48,7 @@ func newSeahorseContextManager(_ json.RawMessage, al *AgentLoop) (ContextManager
mgr := &seahorseContextManager{ mgr := &seahorseContextManager{
engine: engine, engine: engine,
sessions: agent.Sessions, sessions: agent.Sessions,
al: al,
} }
// Register seahorse tools with the agent's tool registry // Register seahorse tools with the agent's tool registry
@@ -160,10 +162,18 @@ func (m *seahorseContextManager) Clear(ctx context.Context, sessionKey string) e
if err := m.engine.ClearSession(ctx, sessionKey); err != nil { if err := m.engine.ClearSession(ctx, sessionKey); err != nil {
return err return err
} }
if m.sessions != nil { // The session may belong to a routed (non-default) agent whose JSONL
m.sessions.SetHistory(sessionKey, []providers.Message{}) // store differs from the bootstrap store, so clear the owner's store.
m.sessions.SetSummary(sessionKey, "") sessions := m.sessions
return m.sessions.Save(sessionKey) 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 return nil
} }
+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...), FinalSuccessfulPath: append([]string(nil), payload.FinalSuccessfulPath...),
SkillContextSnapshots: toEvolutionSkillContextSnapshots(payload.SkillContextSnapshots), SkillContextSnapshots: toEvolutionSkillContextSnapshots(payload.SkillContextSnapshots),
} }
if isEvolutionHeartbeatInput(input) {
return false
}
b.rememberScheduledColdPathWorkspace(input.Workspace) b.rememberScheduledColdPathWorkspace(input.Workspace)
b.closeMu.Lock() b.closeMu.Lock()
@@ -228,6 +231,10 @@ func (b *evolutionBridge) handleTurnEndAsync(meta EventMeta, payload TurnEndPayl
return true return true
} }
func isEvolutionHeartbeatInput(input evolution.TurnCaseInput) bool {
return strings.EqualFold(strings.TrimSpace(input.SessionKey), "heartbeat")
}
func (b *evolutionBridge) subscribeRuntimeEvents(ch runtimeevents.EventChannel) error { func (b *evolutionBridge) subscribeRuntimeEvents(ch runtimeevents.EventChannel) error {
if b == nil || ch == nil { if b == nil || ch == nil {
return 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) 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) { func TestEvolutionBridge_ScheduledModeDoesNotRunColdPathAfterTurn(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
seedReadyRule(t, tmpDir) seedReadyRule(t, tmpDir)
+18 -8
View File
@@ -103,8 +103,25 @@ func NewAgentInstance(
toolsRegistry.Register(tools.NewReadFileBytesTool(workspace, readRestrict, maxReadFileSize, allowReadPaths)) 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") { 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") { if cfg.Tools.IsToolEnabled("list_dir") {
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths)) 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") sessionsDir := filepath.Join(workspace, "sessions")
sessions := initSessionStore(sessionsDir) sessions := initSessionStore(sessionsDir)
+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) { func TestNewAgentInstance_InvalidExecConfigDoesNotExit(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
+9 -4
View File
@@ -495,11 +495,16 @@ func (p *Pipeline) CallLLM(
} }
} }
// Save finishReason to turnState for SubTurn truncation detection // Save finishReason and usage on the turn state. Use ts directly (the
if innerTS := turnStateFromContext(ctx); innerTS != nil { // authoritative turn state for this call) rather than a context lookup:
innerTS.SetLastFinishReason(exec.response.FinishReason) // 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 { if exec.response.Usage != nil {
innerTS.SetLastUsage(exec.response.Usage) ts.SetLastUsage(exec.response.Usage)
} }
} }
+7
View File
@@ -54,6 +54,7 @@ func (p *Pipeline) tryConfiguredStreamingLLM(
channel: ts.channel, channel: ts.channel,
chatID: ts.chatID, chatID: ts.chatID,
modelName: exec.llmModelName, modelName: exec.llmModelName,
ts: ts,
} }
logger.DebugCF("agent", "configured streaming enabled", map[string]any{ logger.DebugCF("agent", "configured streaming enabled", map[string]any{
@@ -376,6 +377,7 @@ type streamingChunkPublisher struct {
published bool published bool
reasoningPublished bool reasoningPublished bool
err error err error
ts *turnState
} }
func (p *streamingChunkPublisher) Update(ctx context.Context, accumulated string) { 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 { if setter, ok := p.streamer.(interface{ SetModelName(modelName string) }); ok {
setter.SetModelName(p.modelName) 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 var err error
if streamer, ok := p.streamer.(bus.ContextUsageStreamer); ok { if streamer, ok := p.streamer.(bus.ContextUsageStreamer); ok {
err = streamer.FinalizeWithContext(ctx, content, contextUsage) err = streamer.FinalizeWithContext(ctx, content, contextUsage)
+1 -1
View File
@@ -146,7 +146,7 @@ func SynthesizeAndStore(
_, err = io.Copy(file, stream) _, err = io.Copy(file, stream)
if err != nil { if err != nil {
file.Close() _ = file.Close()
return "", fmt.Errorf("failed to write tts audio: %w", err) return "", fmt.Errorf("failed to write tts audio: %w", err)
} }
+7 -5
View File
@@ -266,7 +266,7 @@ func (c *BaseChannel) HandleMessageWithContext(
media []string, media []string,
inboundCtx bus.InboundContext, inboundCtx bus.InboundContext,
senderOpts ...bus.SenderInfo, senderOpts ...bus.SenderInfo,
) { ) error {
// Use SenderInfo-based allow check when available, else fall back to string // Use SenderInfo-based allow check when available, else fall back to string
var sender bus.SenderInfo var sender bus.SenderInfo
if len(senderOpts) > 0 { if len(senderOpts) > 0 {
@@ -275,11 +275,11 @@ func (c *BaseChannel) HandleMessageWithContext(
senderID := strings.TrimSpace(inboundCtx.SenderID) senderID := strings.TrimSpace(inboundCtx.SenderID)
if sender.CanonicalID != "" || sender.PlatformID != "" { if sender.CanonicalID != "" || sender.PlatformID != "" {
if !c.IsAllowedSender(sender) { if !c.IsAllowedSender(sender) {
return return nil
} }
} else { } else {
if !c.IsAllowed(senderID) { if !c.IsAllowed(senderID) {
return return nil
} }
} }
@@ -350,7 +350,9 @@ func (c *BaseChannel) HandleMessageWithContext(
"chat_id": deliveryChatID, "chat_id": deliveryChatID,
"error": err.Error(), "error": err.Error(),
}) })
return err
} }
return nil
} }
// HandleInboundContext publishes a normalized inbound message using only the // HandleInboundContext publishes a normalized inbound message using only the
@@ -361,8 +363,8 @@ func (c *BaseChannel) HandleInboundContext(
media []string, media []string,
inboundCtx bus.InboundContext, inboundCtx bus.InboundContext,
senderOpts ...bus.SenderInfo, senderOpts ...bus.SenderInfo,
) { ) error {
c.HandleMessageWithContext(ctx, deliveryChatID, content, media, inboundCtx, senderOpts...) return c.HandleMessageWithContext(ctx, deliveryChatID, content, media, inboundCtx, senderOpts...)
} }
func (c *BaseChannel) SetRunning(running bool) { 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
}
+4 -3
View File
@@ -69,7 +69,8 @@ func NewLINEChannel(
return nil, fmt.Errorf("failed to create LINE messaging client: %w", err) 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.WithMaxMessageLength(5000),
channels.WithGroupTrigger(bc.GroupTrigger), channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID), channels.WithReasoningChannelID(bc.ReasoningChannelID),
@@ -482,7 +483,7 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
Messages: []messaging_api.MessageInterface{&textMsg}, Messages: []messaging_api.MessageInterface{&textMsg},
}) })
if resp != nil && resp.Body != nil { if resp != nil && resp.Body != nil {
resp.Body.Close() _ = resp.Body.Close()
} }
if err == nil { if err == nil {
logger.DebugCF("line", "Message sent via Reply API", map[string]any{ 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. // classifySDKError maps an SDK HTTP response to the project's sentinel errors.
func classifySDKError(resp *http.Response, err error) error { func classifySDKError(resp *http.Response, err error) error {
if resp != nil && resp.Body != nil { if resp != nil && resp.Body != nil {
resp.Body.Close() _ = resp.Body.Close()
} }
if err == nil { if err == nil {
return nil return nil
+68 -9
View File
@@ -13,6 +13,7 @@ import (
"math" "math"
"net" "net"
"net/http" "net/http"
"runtime/debug"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@@ -698,18 +699,34 @@ func setStreamerModelName(streamer any, modelName string) {
setter.SetModelName(modelName) 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 // splitMarkerStreamer turns accumulated streaming text containing
// MessageSplitMarker into separate channel stream messages. // MessageSplitMarker into separate channel stream messages.
type splitMarkerStreamer struct { type splitMarkerStreamer struct {
mu sync.Mutex mu sync.Mutex
current bus.Streamer current bus.Streamer
reasoning bus.ReasoningStreamer reasoning bus.ReasoningStreamer
begin func(context.Context) (bus.Streamer, error) begin func(context.Context) (bus.Streamer, error)
completedParts int completedParts int
finalized bool finalized bool
onFinalize func(context.Context, string) onFinalize func(context.Context, string)
clearMarker func() clearMarker func()
modelName string modelName string
turnInputTokens int
turnOutputTokens int
} }
func (s *splitMarkerStreamer) Update(ctx context.Context, content string) error { func (s *splitMarkerStreamer) Update(ctx context.Context, content string) error {
@@ -760,6 +777,14 @@ func (s *splitMarkerStreamer) SetModelName(modelName string) {
setStreamerModelName(s.reasoning, s.modelName) 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) { func (s *splitMarkerStreamer) Cancel(ctx context.Context) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -839,6 +864,7 @@ func (s *splitMarkerStreamer) ensureCurrentLocked(ctx context.Context) error {
} }
s.current = streamer s.current = streamer
setStreamerModelName(s.current, s.modelName) setStreamerModelName(s.current, s.modelName)
setStreamerTurnUsage(s.current, s.turnInputTokens, s.turnOutputTokens)
return nil return nil
} }
@@ -927,6 +953,10 @@ func (s *finalizeHookStreamer) SetModelName(modelName string) {
setStreamerModelName(s.Streamer, strings.TrimSpace(modelName)) 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) { func (s *finalizeHookStreamer) runFinalizeHook(ctx context.Context, content string) {
if s.onFinalize != nil { if s.onFinalize != nil {
s.onFinalize(ctx, content) s.onFinalize(ctx, content)
@@ -1279,6 +1309,16 @@ func (m *Manager) StartAll(ctx context.Context) error {
for _, listener := range m.httpListeners { for _, listener := range m.httpListeners {
ln := listener ln := listener
go func() { 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{ logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{
"addr": ln.Addr().String(), "addr": ln.Addr().String(),
}) })
@@ -1292,6 +1332,16 @@ func (m *Manager) StartAll(ctx context.Context) error {
} }
} else { } else {
go func() { 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{ logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{
"addr": m.httpServer.Addr, "addr": m.httpServer.Addr,
}) })
@@ -1943,6 +1993,15 @@ func (m *Manager) Reload(ctx context.Context, cfg *config.Config) error {
// Commit hashes only on full success. // Commit hashes only on full success.
m.channelHashes = list m.channelHashes = list
go func() { 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 { for _, f := range deferFuncs {
f() f()
} }
+53
View File
@@ -3383,3 +3383,56 @@ func TestManager_SendPlaceholder(t *testing.T) {
t.Error("expected SendPlaceholder to fail for unknown channel") 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)
}
}
+14 -7
View File
@@ -24,6 +24,7 @@ import (
type OneBotChannel struct { type OneBotChannel struct {
*channels.BaseChannel *channels.BaseChannel
config *config.OneBotSettings config *config.OneBotSettings
downloadFn func(urlStr, filename string) string
conn *websocket.Conn conn *websocket.Conn
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
@@ -194,7 +195,7 @@ func (c *OneBotChannel) connect() error {
conn, resp, err := dialer.Dial(c.config.WSUrl, header) conn, resp, err := dialer.Dial(c.config.WSUrl, header)
if resp != nil { if resp != nil {
resp.Body.Close() _ = resp.Body.Close()
} }
if err != nil { if err != nil {
return err return err
@@ -795,9 +796,7 @@ func (c *OneBotChannel) parseMessageSegments(
} else if n, ok := data["name"].(string); ok && n != "" { } else if n, ok := data["name"].(string); ok && n != "" {
filename = n filename = n
} }
localPath := utils.DownloadFile(url, filename, utils.DownloadOptions{ localPath := c.downloadInboundFile(url, filename)
LoggerPrefix: "onebot",
})
if localPath != "" { if localPath != "" {
mediaRefs = append(mediaRefs, storeFile(localPath, filename)) mediaRefs = append(mediaRefs, storeFile(localPath, filename))
textParts = append(textParts, fmt.Sprintf("[%s]", segType)) textParts = append(textParts, fmt.Sprintf("[%s]", segType))
@@ -809,9 +808,7 @@ func (c *OneBotChannel) parseMessageSegments(
if data != nil { if data != nil {
url, _ := data["url"].(string) url, _ := data["url"].(string)
if url != "" { if url != "" {
localPath := utils.DownloadFile(url, "voice.amr", utils.DownloadOptions{ localPath := c.downloadInboundFile(url, "voice.amr")
LoggerPrefix: "onebot",
})
if localPath != "" { if localPath != "" {
textParts = append(textParts, "[voice]") textParts = append(textParts, "[voice]")
mediaRefs = append(mediaRefs, storeFile(localPath, "voice.amr")) 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) { func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
switch raw.PostType { switch raw.PostType {
case "message": case "message":
+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) ws, resp, err := websocket.DefaultDialer.DialContext(c.ctx, c.config.URL, header)
if resp != nil && resp.Body != nil { if resp != nil && resp.Body != nil {
resp.Body.Close() _ = resp.Body.Close()
} }
if err != nil { if err != nil {
return err return err
+40 -1
View File
@@ -105,6 +105,8 @@ type PicoChannel struct {
cancel context.CancelFunc cancel context.CancelFunc
progress *channels.ToolFeedbackAnimator progress *channels.ToolFeedbackAnimator
deleteMessageFn func(context.Context, string, string) error 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. // NewPicoChannel creates a new Pico Protocol channel.
@@ -531,6 +533,8 @@ type picoStreamer struct {
channel *PicoChannel channel *PicoChannel
chatID string chatID string
modelName string modelName string
turnInputTokens int
turnOutputTokens int
messageID string messageID string
reasoningID string reasoningID string
throttleInterval time.Duration throttleInterval time.Duration
@@ -553,6 +557,17 @@ func (s *picoStreamer) SetModelName(modelName string) {
s.modelName = strings.TrimSpace(modelName) 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 { func (s *picoStreamer) Update(ctx context.Context, content string) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -661,8 +676,9 @@ func (s *picoStreamer) sendLocked(ctx context.Context, content string, contextUs
payload[PayloadKeyModelName] = s.modelName payload[PayloadKeyModelName] = s.modelName
} }
setContextUsagePayload(payload, contextUsage) setContextUsagePayload(payload, contextUsage)
setTurnUsagePayload(payload, s.turnInputTokens, s.turnOutputTokens)
outMsg := newMessage(TypeMessageCreate, payload) 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 return err
} }
} else if content != s.lastContent || contextUsage != nil { } else if content != s.lastContent || contextUsage != nil {
@@ -673,6 +689,7 @@ func (s *picoStreamer) sendLocked(ctx context.Context, content string, contextUs
if s.modelName != "" { if s.modelName != "" {
payload[PayloadKeyModelName] = 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 { if err := s.channel.editMessagePayload(ctx, s.chatID, s.messageID, payload, contextUsage); err != nil {
return err return err
} }
@@ -932,6 +949,14 @@ func (c *PicoChannel) handleMediaDownload(w http.ResponseWriter, r *http.Request
http.ServeContent(w, r, filename, info.ModTime(), file) 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. // broadcastToSession sends a message to all connections with a matching session.
func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error { func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error {
// chatID format: "pico:<sessionID>" // chatID format: "pico:<sessionID>"
@@ -1403,6 +1428,20 @@ func setContextUsagePayload(payload map[string]any, u *bus.ContextUsage) {
} }
} }
// 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,
}
}
func picoToolCallsPayload(msg bus.OutboundMessage) ([]utils.VisibleToolCall, bool) { func picoToolCallsPayload(msg bus.OutboundMessage) ([]utils.VisibleToolCall, bool) {
raw := strings.TrimSpace(msg.Context.Raw[PayloadKeyToolCalls]) raw := strings.TrimSpace(msg.Context.Raw[PayloadKeyToolCalls])
if raw == "" { if raw == "" {
+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" PayloadKeyPlaceholder = "placeholder"
PayloadKeyToolCalls = "tool_calls" PayloadKeyToolCalls = "tool_calls"
PayloadKeyModelName = "model_name" PayloadKeyModelName = "model_name"
PayloadKeyUsage = "usage"
MessageKindThought = "thought" MessageKindThought = "thought"
MessageKindToolCalls = "tool_calls" MessageKindToolCalls = "tool_calls"
+1 -1
View File
@@ -1185,7 +1185,7 @@ func (c *TelegramChannel) handleMessages(ctx context.Context, messages []*telego
inboundCtx := bus.InboundContext{ inboundCtx := bus.InboundContext{
Channel: c.Name(), Channel: c.Name(),
ChatID: fmt.Sprintf("%d", chatID), ChatID: compositeChatID,
ChatType: peerKind, ChatType: peerKind,
SenderID: platformID, SenderID: platformID,
MessageID: messageID, MessageID: messageID,
+32 -2
View File
@@ -5,6 +5,8 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"io" "io"
"net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@@ -213,6 +215,33 @@ func TestSendMedia_ImageFallbacksToDocumentOnInvalidDimensions(t *testing.T) {
assert.Equal(t, "caption", constructor.calls[1].Parameters["caption"]) assert.Equal(t, "caption", constructor.calls[1].Parameters["caption"])
} }
func TestDownloadFileWithInfo_AllowsLocalConfiguredBaseURL(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got, want := r.URL.Path, "/file/bot"+testToken+"/photos/image"; got != want {
t.Fatalf("request path = %q, want %q", got, want)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("telegram-local-bot-api"))
}))
defer server.Close()
ch, err := NewTelegramChannel(
&config.Channel{Type: config.ChannelTelegram, Enabled: true},
&config.TelegramSettings{
Token: *config.NewSecureString(testToken),
BaseURL: server.URL,
},
nil,
)
require.NoError(t, err)
path := ch.downloadFileWithInfo(&telego.File{FilePath: "photos/image"}, "")
if path == "" {
t.Fatal("expected local base_url download to succeed")
}
defer os.Remove(path)
}
func TestSendMedia_ImageNonDimensionErrorDoesNotFallback(t *testing.T) { func TestSendMedia_ImageNonDimensionErrorDoesNotFallback(t *testing.T) {
constructor := &multipartRecordingConstructor{} constructor := &multipartRecordingConstructor{}
caller := &stubCaller{ caller := &stubCaller{
@@ -1212,8 +1241,9 @@ func TestHandleMessage_ForumTopic_SetsMetadata(t *testing.T) {
inbound, ok := <-messageBus.InboundChan() inbound, ok := <-messageBus.InboundChan()
require.True(t, ok, "expected inbound message") require.True(t, ok, "expected inbound message")
// ChatID remains the parent chat; TopicID isolates the sub-conversation. // ChatID includes the thread ID for forum topics so outbound
assert.Equal(t, "-1001234567890", inbound.ChatID) // delivery resolves the correct topic without relying solely on TopicID fallback.
assert.Equal(t, "-1001234567890/42", inbound.ChatID)
assert.Equal(t, "group", inbound.Context.ChatType) assert.Equal(t, "group", inbound.Context.ChatType)
assert.Equal(t, "42", inbound.Context.TopicID) assert.Equal(t, "42", inbound.Context.TopicID)
} }
+1 -1
View File
@@ -62,7 +62,7 @@ func (c *WhatsAppChannel) Start(ctx context.Context) error {
conn, resp, err := dialer.Dial(c.url, nil) conn, resp, err := dialer.Dial(c.url, nil)
if resp != nil { if resp != nil {
resp.Body.Close() _ = resp.Body.Close()
} }
if err != nil { if err != nil {
c.cancel() c.cancel()
+30 -3
View File
@@ -599,6 +599,31 @@ type MatrixSettings struct {
CryptoPassphrase string `json:"crypto_passphrase,omitempty" yaml:"-"` CryptoPassphrase string `json:"crypto_passphrase,omitempty" yaml:"-"`
} }
// DeltaChatSettings configures the Delta Chat channel. Delta Chat is an
// email-based, end-to-end encrypted messenger; PicoClaw talks to a local
// `deltachat-rpc-server` process over JSON-RPC (stdio).
//
// Email is the only required setting. A full address selects an already
// configured account in DataDir; a first-run marker such as "@nine.testrun.org"
// creates a chatmail account and tells the user which full email to save.
// Mailbox credentials stay in the Delta Chat account store. DisplayName and
// AvatarImage are optional profile settings applied on startup. Password remains
// only for legacy PicoClaw-managed email configuration.
type DeltaChatSettings struct {
Email string `json:"email" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_EMAIL"`
Password SecureString `json:"password,omitzero" yaml:"password,omitempty" env:"PICOCLAW_CHANNELS_DELTACHAT_PASSWORD"`
DisplayName string `json:"display_name,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_DISPLAY_NAME"`
AvatarImage string `json:"avatar_image,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_AVATAR_IMAGE"`
DataDir string `json:"data_dir,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_DATA_DIR"`
RPCServerPath string `json:"rpc_server_path,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_RPC_SERVER_PATH"`
InviteLink string `json:"invite_link,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_INVITE_LINK"`
AllowCrosspost bool `json:"allow_crosspost,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_ALLOW_CROSSPOST"`
IMAPServer string `json:"imap_server,omitempty" yaml:"-"`
IMAPPort int `json:"imap_port,omitempty" yaml:"-"`
SMTPServer string `json:"smtp_server,omitempty" yaml:"-"`
SMTPPort int `json:"smtp_port,omitempty" yaml:"-"`
}
type LINESettings struct { type LINESettings struct {
ChannelSecret SecureString `json:"channel_secret,omitzero" yaml:"channel_secret,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` ChannelSecret SecureString `json:"channel_secret,omitzero" yaml:"channel_secret,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
ChannelAccessToken SecureString `json:"channel_access_token,omitzero" yaml:"channel_access_token,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"` ChannelAccessToken SecureString `json:"channel_access_token,omitzero" yaml:"channel_access_token,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
@@ -1028,9 +1053,11 @@ type WebToolsConfig struct {
} }
type CronToolsConfig struct { type CronToolsConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"` ToolConfig `envPrefix:"PICOCLAW_TOOLS_CRON_"`
ExecTimeoutMinutes int ` json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout // 0 means no timeout.
AllowCommand bool ` json:"allow_command" env:"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND"` ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"`
AllowCommand bool `json:"allow_command" env:"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND"`
CommandAllowedRemotes []string `json:"command_allowed_remotes" env:"PICOCLAW_TOOLS_CRON_COMMAND_ALLOWED_REMOTES"`
} }
type ExecConfig struct { type ExecConfig struct {
+21
View File
@@ -7,6 +7,7 @@ import (
"reflect" "reflect"
"strconv" "strconv"
"strings" "strings"
"sync"
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -26,6 +27,7 @@ const (
ChannelDingTalk = "dingtalk" ChannelDingTalk = "dingtalk"
ChannelSlack = "slack" ChannelSlack = "slack"
ChannelMatrix = "matrix" ChannelMatrix = "matrix"
ChannelDeltaChat = "deltachat"
ChannelLINE = "line" ChannelLINE = "line"
ChannelOneBot = "onebot" ChannelOneBot = "onebot"
ChannelQQ = "qq" ChannelQQ = "qq"
@@ -655,6 +657,8 @@ func filterSecureFields(r RawNode, secureFields map[string]struct{}) RawNode {
// channelSettingsFactory maps channel type to a zero-value prototype of the // channelSettingsFactory maps channel type to a zero-value prototype of the
// corresponding Settings struct. InitChannelList uses reflect.New to create // corresponding Settings struct. InitChannelList uses reflect.New to create
// fresh instances, avoiding repeated closure boilerplate. // fresh instances, avoiding repeated closure boilerplate.
var channelSettingsMu sync.RWMutex
var channelSettingsFactory = map[string]any{ var channelSettingsFactory = map[string]any{
ChannelPico: (PicoSettings{}), ChannelPico: (PicoSettings{}),
ChannelPicoClient: (PicoClientSettings{}), ChannelPicoClient: (PicoClientSettings{}),
@@ -666,6 +670,7 @@ var channelSettingsFactory = map[string]any{
ChannelDingTalk: (DingTalkSettings{}), ChannelDingTalk: (DingTalkSettings{}),
ChannelSlack: (SlackSettings{}), ChannelSlack: (SlackSettings{}),
ChannelMatrix: (MatrixSettings{}), ChannelMatrix: (MatrixSettings{}),
ChannelDeltaChat: (DeltaChatSettings{}),
ChannelLINE: (LINESettings{}), ChannelLINE: (LINESettings{}),
ChannelOneBot: (OneBotSettings{}), ChannelOneBot: (OneBotSettings{}),
ChannelQQ: (QQSettings{}), ChannelQQ: (QQSettings{}),
@@ -679,10 +684,24 @@ var channelSettingsFactory = map[string]any{
ChannelSlackWebHook: (SlackWebhookSettings{}), ChannelSlackWebHook: (SlackWebhookSettings{}),
} }
// RegisterChannelSettings registers a settings struct prototype for a custom
// channel type. External packages (out-of-tree channels registered via
// channels.RegisterFactory) call this from an init() so their channel type
// passes config validation (isValidChannelType) and its settings block decodes
// into the right struct (newChannelSettings). The prototype must be a struct
// value, e.g. RegisterChannelSettings("my_channel", MyChannelSettings{}).
func RegisterChannelSettings(channelType string, prototype any) {
channelSettingsMu.Lock()
defer channelSettingsMu.Unlock()
channelSettingsFactory[channelType] = prototype
}
// newChannelSettings creates a fresh zero-value pointer for the given channel type. // newChannelSettings creates a fresh zero-value pointer for the given channel type.
// Returns nil if the type is not registered. // Returns nil if the type is not registered.
func newChannelSettings(channelType string) any { func newChannelSettings(channelType string) any {
channelSettingsMu.RLock()
proto, ok := channelSettingsFactory[channelType] proto, ok := channelSettingsFactory[channelType]
channelSettingsMu.RUnlock()
if !ok { if !ok {
return nil return nil
} }
@@ -691,7 +710,9 @@ func newChannelSettings(channelType string) any {
// isValidChannelType returns true if the channel type is a known, registered type. // isValidChannelType returns true if the channel type is a known, registered type.
func isValidChannelType(channelType string) bool { func isValidChannelType(channelType string) bool {
channelSettingsMu.RLock()
_, ok := channelSettingsFactory[channelType] _, ok := channelSettingsFactory[channelType]
channelSettingsMu.RUnlock()
return ok return ok
} }
+68
View File
@@ -1019,6 +1019,38 @@ func TestDefaultConfig_ChannelStreamingDisabled(t *testing.T) {
} }
} }
func TestDefaultConfig_DeltaChatExample(t *testing.T) {
cfg := DefaultConfig()
deltachat := cfg.Channels.Get(ChannelDeltaChat)
if deltachat == nil {
t.Fatal("DefaultConfig() missing deltachat channel")
}
if deltachat.Enabled {
t.Fatal("DefaultConfig().deltachat should be disabled")
}
if !deltachat.GroupTrigger.MentionOnly {
t.Fatal("DefaultConfig().deltachat should use mention-only group trigger")
}
decoded, err := deltachat.GetDecoded()
if err != nil {
t.Fatalf("deltachat GetDecoded() error = %v", err)
}
settings, ok := decoded.(*DeltaChatSettings)
if !ok {
t.Fatalf("deltachat settings type = %T, want *DeltaChatSettings", decoded)
}
if settings.Email != "@nine.testrun.org" {
t.Fatalf("DefaultConfig().deltachat.settings.email = %q, want @nine.testrun.org", settings.Email)
}
if settings.Password.String() != "" {
t.Fatal("DefaultConfig().deltachat.settings.password should be empty")
}
if settings.DisplayName == "" {
t.Fatal("DefaultConfig().deltachat.settings.display_name should be populated")
}
}
func TestValidateSingletonChannels_RejectsMultipleInstances(t *testing.T) { func TestValidateSingletonChannels_RejectsMultipleInstances(t *testing.T) {
channels := ChannelsConfig{ channels := ChannelsConfig{
"pico1": &Channel{Enabled: true, Type: ChannelPico}, "pico1": &Channel{Enabled: true, Type: ChannelPico},
@@ -1540,6 +1572,16 @@ func TestDefaultConfig_CronAllowCommandEnabled(t *testing.T) {
} }
} }
func TestDefaultConfig_CronCommandAllowedRemotesEmpty(t *testing.T) {
cfg := DefaultConfig()
if len(cfg.Tools.Cron.CommandAllowedRemotes) != 0 {
t.Fatalf(
"DefaultConfig().Tools.Cron.CommandAllowedRemotes = %#v, want empty",
cfg.Tools.Cron.CommandAllowedRemotes,
)
}
}
func TestDefaultConfig_HooksDefaults(t *testing.T) { func TestDefaultConfig_HooksDefaults(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
if !cfg.Hooks.Enabled { if !cfg.Hooks.Enabled {
@@ -1600,6 +1642,32 @@ func TestLoadConfig_CronAllowCommandDefaultsTrueWhenUnset(t *testing.T) {
} }
} }
func TestLoadConfig_CronCommandAllowedRemotes(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(
configPath,
[]byte(`{"version":1,"tools":{"cron":{"command_allowed_remotes":["telegram:1234567890","discord"]}}}`),
0o600,
); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
want := []string{"telegram:1234567890", "discord"}
if len(cfg.Tools.Cron.CommandAllowedRemotes) != len(want) {
t.Fatalf("CommandAllowedRemotes = %#v, want %#v", cfg.Tools.Cron.CommandAllowedRemotes, want)
}
for i := range want {
if cfg.Tools.Cron.CommandAllowedRemotes[i] != want[i] {
t.Fatalf("CommandAllowedRemotes = %#v, want %#v", cfg.Tools.Cron.CommandAllowedRemotes, want)
}
}
}
func TestLoadConfig_WebToolsProxy(t *testing.T) { func TestLoadConfig_WebToolsProxy(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json") configPath := filepath.Join(tmpDir, "config.json")
+15
View File
@@ -108,6 +108,14 @@ func DefaultConfig() *Config {
APIBase: "https://api.venice.ai/api/v1", APIBase: "https://api.venice.ai/api/v1",
}, },
// NEAR AI Cloud TEE inference - https://near.ai
{
ModelName: "nearai-glm",
Provider: "nearai",
Model: "zai-org/GLM-5.1-FP8",
APIBase: "https://cloud-api.near.ai/v1",
},
// Google Gemini - https://ai.google.dev/ // Google Gemini - https://ai.google.dev/
{ {
ModelName: "gemini-2.0-flash", ModelName: "gemini-2.0-flash",
@@ -541,6 +549,13 @@ func defaultChannels() ChannelsConfig {
"join_on_invite": true, "join_on_invite": true,
}, },
}, },
"deltachat": map[string]any{
"group_trigger": map[string]any{"mention_only": true},
"settings": map[string]any{
"email": "@nine.testrun.org",
"display_name": "PicoClaw Bot",
},
},
"line": map[string]any{ "line": map[string]any{
"group_trigger": map[string]any{"mention_only": true}, "group_trigger": map[string]any{"mention_only": true},
"settings": map[string]any{ "settings": map[string]any{
@@ -0,0 +1,54 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// customChannelSettings stands in for an out-of-tree channel's settings struct.
type customChannelSettings struct {
Token string `json:"token"`
}
// TestRegisterChannelSettings verifies the public registration hook makes a
// previously-unknown channel type valid and decodable — the behavior out-of-tree
// channels rely on (they call RegisterChannelSettings from init()).
func TestRegisterChannelSettings(t *testing.T) {
const typ = "custom_test_channel"
assert.False(t, isValidChannelType(typ), "type should be unknown before registration")
RegisterChannelSettings(typ, customChannelSettings{})
assert.True(t, isValidChannelType(typ), "type should be valid after registration")
got := newChannelSettings(typ)
_, ok := got.(*customChannelSettings)
assert.Truef(t, ok, "newChannelSettings(%q) = %T, want *customChannelSettings", typ, got)
}
// TestRegisterChannelSettings_InitChannelList verifies that a config carrying a
// registered out-of-tree channel type passes InitChannelList and decodes its
// settings — the full path that previously errored with "unknown type".
func TestRegisterChannelSettings_InitChannelList(t *testing.T) {
const typ = "custom_initlist_channel"
RegisterChannelSettings(typ, customChannelSettings{})
channels := ChannelsConfig{
"mychan": {
Type: typ,
Enabled: true,
Settings: RawNode(`{"token":"secret-123"}`),
},
}
require.NoError(t, InitChannelList(channels))
decoded, err := channels["mychan"].GetDecoded()
require.NoError(t, err)
cfg, ok := decoded.(*customChannelSettings)
require.True(t, ok)
assert.Equal(t, "secret-123", cfg.Token)
}
+23
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"log" "log"
"runtime/debug"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -227,6 +228,11 @@ func (s *eventSubscription) dispatch(ctx context.Context, evt Event) {
s.wg.Add(1) s.wg.Add(1)
go func() { go func() {
defer s.wg.Done() defer s.wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("events: subscriber %q goroutine panic recovered: %v\n%s", s.name, r, debug.Stack())
}
}()
s.handle(ctx, evt) s.handle(ctx, evt)
}() }()
case Keyed: case Keyed:
@@ -253,6 +259,15 @@ func (s *eventSubscription) handle(ctx context.Context, evt Event) {
done := make(chan handlerResult, 1) done := make(chan handlerResult, 1)
go func() { go func() {
defer func() {
if r := recover(); r != nil {
log.Printf(
"events: subscriber %q timeout-handler goroutine panic recovered: %v\n%s",
s.name, r, debug.Stack(),
)
done <- handlerResult{panicked: true}
}
}()
done <- s.invokeHandler(ctx, evt) done <- s.invokeHandler(ctx, evt)
}() }()
@@ -302,6 +317,14 @@ func (s *eventSubscription) watchContext(ctx context.Context) {
} }
go func() { go func() {
defer func() {
if r := recover(); r != nil {
log.Printf(
"events: subscriber %q watchContext goroutine panic recovered: %v\n%s",
s.name, r, debug.Stack(),
)
}
}()
select { select {
case <-ctx.Done(): case <-ctx.Done():
_ = s.Close() _ = s.Close()
+14 -4
View File
@@ -558,10 +558,20 @@ func isInvalidJSON(err error) bool {
} }
func lockStoreFile(path string) func() { func lockStoreFile(path string) func() {
actual, _ := storeFileLocks.LoadOrStore(path, &sync.Mutex{}) for {
mu := actual.(*sync.Mutex) actual, _ := storeFileLocks.LoadOrStore(path, &sync.Mutex{})
mu.Lock() mu, ok := actual.(*sync.Mutex)
return mu.Unlock if !ok || mu == nil {
// Corrupted entry (wrong type or nil *sync.Mutex).
// Atomically swap in a fresh mutex via CompareAndSwap.
// If CAS fails, another goroutine already replaced it —
// just retry the loop to pick up the valid entry.
storeFileLocks.CompareAndSwap(path, actual, &sync.Mutex{})
continue
}
mu.Lock()
return mu.Unlock
}
} }
func (s *Store) profilePath(workspaceID, skillName string) (string, error) { func (s *Store) profilePath(workspaceID, skillName string) (string, error) {
+1 -1
View File
@@ -110,7 +110,7 @@ func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
// This prevents the renamed file from disappearing after a crash // This prevents the renamed file from disappearing after a crash
if dirFile, err := os.Open(dir); err == nil { if dirFile, err := os.Open(dir); err == nil {
_ = dirFile.Sync() _ = dirFile.Sync()
dirFile.Close() _ = dirFile.Close()
} }
// Success: skip cleanup (file was renamed, no temp to remove) // Success: skip cleanup (file was renamed, no temp to remove)
+53 -11
View File
@@ -19,6 +19,7 @@ import (
"github.com/sipeed/picoclaw/pkg/audio/tts" "github.com/sipeed/picoclaw/pkg/audio/tts"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/channels"
_ "github.com/sipeed/picoclaw/pkg/channels/deltachat"
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk" _ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
_ "github.com/sipeed/picoclaw/pkg/channels/discord" _ "github.com/sipeed/picoclaw/pkg/channels/discord"
_ "github.com/sipeed/picoclaw/pkg/channels/feishu" _ "github.com/sipeed/picoclaw/pkg/channels/feishu"
@@ -205,18 +206,11 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr
publishGatewayEvent(agentLoop, runtimeevents.KindGatewayStart, startedAt, nil) publishGatewayEvent(agentLoop, runtimeevents.KindGatewayStart, startedAt, nil)
fmt.Println("\n📦 Agent Status:") fmt.Println("\n📦 Agent Status:")
startupInfo := agentLoop.GetStartupInfo() startupStatus := collectGatewayStartupStatus(agentLoop.GetStartupInfo())
toolsInfo := startupInfo["tools"].(map[string]any) fmt.Printf(" • Tools: %d loaded\n", startupStatus.toolsCount)
skillsInfo := startupInfo["skills"].(map[string]any) fmt.Printf(" • Skills: %d/%d available\n", startupStatus.skillsAvailable, startupStatus.skillsTotal)
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
fmt.Printf(" • Skills: %d/%d available\n", skillsInfo["available"], skillsInfo["total"])
logger.InfoCF("agent", "Agent initialized", logger.InfoCF("agent", "Agent initialized", startupStatus.logFields)
map[string]any{
"tools_count": toolsInfo["count"],
"skills_total": skillsInfo["total"],
"skills_available": skillsInfo["available"],
})
runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus, pidData.Token, listenResult) runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus, pidData.Token, listenResult)
if err != nil { if err != nil {
@@ -315,6 +309,54 @@ func preCheckConfig(cfg *config.Config) error {
return nil return nil
} }
type gatewayStartupStatus struct {
toolsCount int
skillsAvailable int
skillsTotal int
logFields map[string]any
}
func collectGatewayStartupStatus(startupInfo map[string]any) gatewayStartupStatus {
status := gatewayStartupStatus{logFields: map[string]any{}}
if toolsInfo, ok := startupInfo["tools"].(map[string]any); ok {
if count, ok := startupInfoInt(toolsInfo["count"]); ok {
status.toolsCount = count
status.logFields["tools_count"] = count
}
}
if skillsInfo, ok := startupInfo["skills"].(map[string]any); ok {
if total, ok := startupInfoInt(skillsInfo["total"]); ok {
status.skillsTotal = total
status.logFields["skills_total"] = total
}
if available, ok := startupInfoInt(skillsInfo["available"]); ok {
status.skillsAvailable = available
status.logFields["skills_available"] = available
}
}
return status
}
func startupInfoInt(value any) (int, bool) {
switch v := value.(type) {
case int:
return v, true
case int32:
return int(v), true
case int64:
return int(v), true
case float32:
return int(v), true
case float64:
return int(v), true
default:
return 0, false
}
}
func executeReload( func executeReload(
ctx context.Context, ctx context.Context,
agentLoop *agent.AgentLoop, agentLoop *agent.AgentLoop,
+88
View File
@@ -7,6 +7,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"reflect"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -113,6 +114,93 @@ func TestGatewayRunStartupFailureHelper(t *testing.T) {
os.Exit(0) os.Exit(0)
} }
func TestCollectGatewayStartupStatusHandlesMalformedInfo(t *testing.T) {
t.Parallel()
tests := []struct {
name string
startupInfo map[string]any
wantToolsCount int
wantSkillsAvailable int
wantSkillsTotal int
wantLogFields map[string]any
}{
{
name: "missing info",
startupInfo: map[string]any{},
wantLogFields: map[string]any{},
},
{
name: "wrong map shapes",
startupInfo: map[string]any{
"tools": "unexpected",
"skills": []any{"unexpected"},
},
wantLogFields: map[string]any{},
},
{
name: "valid startup info",
startupInfo: map[string]any{
"tools": map[string]any{
"count": 3,
},
"skills": map[string]any{
"available": 2,
"total": 5,
},
},
wantToolsCount: 3,
wantSkillsAvailable: 2,
wantSkillsTotal: 5,
wantLogFields: map[string]any{
"tools_count": 3,
"skills_available": 2,
"skills_total": 5,
},
},
{
name: "json number startup info",
startupInfo: map[string]any{
"tools": map[string]any{
"count": float64(4),
},
"skills": map[string]any{
"available": float64(1),
"total": float64(6),
},
},
wantToolsCount: 4,
wantSkillsAvailable: 1,
wantSkillsTotal: 6,
wantLogFields: map[string]any{
"tools_count": 4,
"skills_available": 1,
"skills_total": 6,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := collectGatewayStartupStatus(tt.startupInfo)
if got.toolsCount != tt.wantToolsCount {
t.Fatalf("toolsCount = %d, want %d", got.toolsCount, tt.wantToolsCount)
}
if got.skillsAvailable != tt.wantSkillsAvailable {
t.Fatalf("skillsAvailable = %d, want %d", got.skillsAvailable, tt.wantSkillsAvailable)
}
if got.skillsTotal != tt.wantSkillsTotal {
t.Fatalf("skillsTotal = %d, want %d", got.skillsTotal, tt.wantSkillsTotal)
}
if !reflect.DeepEqual(got.logFields, tt.wantLogFields) {
t.Fatalf("logFields = %#v, want %#v", got.logFields, tt.wantLogFields)
}
})
}
}
func TestPublishGatewayEvent(t *testing.T) { func TestPublishGatewayEvent(t *testing.T) {
eventBus := runtimeevents.NewBus() eventBus := runtimeevents.NewBus()
t.Cleanup(func() { t.Cleanup(func() {
+5 -5
View File
@@ -162,7 +162,7 @@ func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "reload triggered"}) _ = json.NewEncoder(w).Encode(map[string]string{"status": "reload triggered"})
} }
func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
@@ -176,7 +176,7 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
PID: os.Getpid(), PID: os.Getpid(),
} }
json.NewEncoder(w).Encode(resp) _ = json.NewEncoder(w).Encode(resp)
} }
func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) { func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
@@ -190,7 +190,7 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
if !ready { if !ready {
w.WriteHeader(http.StatusServiceUnavailable) w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(StatusResponse{ _ = json.NewEncoder(w).Encode(StatusResponse{
Status: "not ready", Status: "not ready",
Checks: checks, Checks: checks,
}) })
@@ -200,7 +200,7 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
for _, check := range checks { for _, check := range checks {
if check.Status == "fail" { if check.Status == "fail" {
w.WriteHeader(http.StatusServiceUnavailable) w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(StatusResponse{ _ = json.NewEncoder(w).Encode(StatusResponse{
Status: "not ready", Status: "not ready",
Checks: checks, Checks: checks,
}) })
@@ -210,7 +210,7 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
uptime := time.Since(s.startTime) uptime := time.Since(s.startTime)
json.NewEncoder(w).Encode(StatusResponse{ _ = json.NewEncoder(w).Encode(StatusResponse{
Status: "ready", Status: "ready",
Uptime: uptime.String(), Uptime: uptime.String(),
Checks: checks, Checks: checks,
+9 -2
View File
@@ -181,8 +181,15 @@ func buildParams(
blocks = append(blocks, anthropic.NewTextBlock(msg.Content)) blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
} }
for _, tc := range msg.ToolCalls { for _, tc := range msg.ToolCalls {
// Resolve tool name: prefer tc.Name, fallback to tc.Function.Name
// (tc.Name/tc.Arguments are json:"-" and may be empty when
// history is reloaded from the session store)
toolName := tc.Name
if toolName == "" && tc.Function != nil {
toolName = tc.Function.Name
}
// Skip tool calls with empty names to avoid API errors // Skip tool calls with empty names to avoid API errors
if tc.Name == "" { if toolName == "" {
continue continue
} }
args := tc.Arguments args := tc.Arguments
@@ -194,7 +201,7 @@ func buildParams(
if args == nil { if args == nil {
args = map[string]any{} args = map[string]any{}
} }
blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, args, tc.Name)) blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, args, toolName))
} }
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...)) anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
} else { } else {
+119
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"reflect"
"sync/atomic" "sync/atomic"
"testing" "testing"
@@ -77,6 +78,124 @@ func TestBuildParams_ToolCallMessage(t *testing.T) {
} }
} }
// TestBuildParams_ToolCallFunctionFallback verifies that tool calls whose
// runtime-only fields were lost in a JSON round-trip through the session store
// (ToolCall.Name/Arguments are json:"-"; only ToolCall.Function survives) fall
// back to Function.Name / Function.Arguments, so the tool_use block is still
// emitted and its tool_result pair stays intact. Without the fallback the
// tool_use is skipped and the orphaned tool_result 400s at the API
// ("unexpected tool_use_id found in tool_result blocks").
func TestBuildParams_ToolCallFunctionFallback(t *testing.T) {
tests := []struct {
name string
toolCall ToolCall
wantSkipped bool
wantToolName string
wantInput map[string]any
}{
{
name: "deserialized history shape falls back to Function fields",
toolCall: ToolCall{
ID: "toolu-fallback-1",
Name: "",
Arguments: nil,
Function: &FunctionCall{Name: "x", Arguments: `{"a":1}`},
},
wantToolName: "x",
wantInput: map[string]any{"a": float64(1)},
},
{
name: "runtime shape with Name set and Function nil still works",
toolCall: ToolCall{
ID: "toolu-runtime-1",
Name: "y",
Arguments: map[string]any{"b": 2},
},
wantToolName: "y",
wantInput: map[string]any{"b": 2},
},
{
name: "both Name and Function.Name empty is skipped",
toolCall: ToolCall{
ID: "toolu-empty-1",
Name: "",
Function: &FunctionCall{Name: "", Arguments: `{"c":3}`},
},
wantSkipped: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
messages := []Message{
{Role: "user", Content: "run the tool"},
{Role: "assistant", Content: "", ToolCalls: []ToolCall{tt.toolCall}},
{Role: "tool", ToolCallID: tt.toolCall.ID, Content: "result"},
}
params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{})
if err != nil {
t.Fatalf("buildParams() error: %v", err)
}
if len(params.Messages) != 3 {
t.Fatalf("len(Messages) = %d, want 3", len(params.Messages))
}
assistantMsg := params.Messages[1]
var toolUses []*anthropic.ToolUseBlockParam
for _, block := range assistantMsg.Content {
if block.OfToolUse != nil {
toolUses = append(toolUses, block.OfToolUse)
}
}
// The tool_result in the following user message always carries the
// original ID; look it up once for both branches.
toolResultMsg := params.Messages[2]
if len(toolResultMsg.Content) != 1 || toolResultMsg.Content[0].OfToolResult == nil {
t.Fatalf("message after assistant = %+v, want single tool_result block", toolResultMsg.Content)
}
toolResult := toolResultMsg.Content[0].OfToolResult
if tt.wantSkipped {
if len(toolUses) != 0 {
t.Fatalf("tool_use blocks = %d, want 0 (tool call skipped)", len(toolUses))
}
// Note: matching current behavior, the orphaned tool_result is
// still emitted even though its tool_use block was skipped.
if toolResult.ToolUseID != tt.toolCall.ID {
t.Fatalf("orphaned tool_result ToolUseID = %q, want %q",
toolResult.ToolUseID, tt.toolCall.ID)
}
return
}
// (a) tool_use block emitted with resolved name, id, and parsed input.
if len(toolUses) != 1 {
t.Fatalf("tool_use blocks = %d, want 1", len(toolUses))
}
toolUse := toolUses[0]
if toolUse.Name != tt.wantToolName {
t.Errorf("tool_use Name = %q, want %q", toolUse.Name, tt.wantToolName)
}
if toolUse.ID != tt.toolCall.ID {
t.Errorf("tool_use ID = %q, want %q", toolUse.ID, tt.toolCall.ID)
}
gotInput, ok := toolUse.Input.(map[string]any)
if !ok || !reflect.DeepEqual(gotInput, tt.wantInput) {
t.Errorf("tool_use Input = %#v, want %#v", toolUse.Input, tt.wantInput)
}
// (b) the following user message's tool_result references the same
// id as the tool_use block — the pair is intact.
if toolResult.ToolUseID != toolUse.ID {
t.Errorf("tool_result ToolUseID = %q, want %q (paired with tool_use)",
toolResult.ToolUseID, toolUse.ID)
}
})
}
}
func TestBuildParams_WithTools(t *testing.T) { func TestBuildParams_WithTools(t *testing.T) {
tools := []ToolDefinition{ tools := []ToolDefinition{
{ {
+17 -3
View File
@@ -233,12 +233,26 @@ func buildRequestBody(
// Add tool_use blocks // Add tool_use blocks
for _, tc := range msg.ToolCalls { for _, tc := range msg.ToolCalls {
if strings.TrimSpace(tc.Name) == "" { // Resolve tool name: prefer tc.Name, fallback to tc.Function.Name
// (tc.Name/tc.Arguments are json:"-" and may be empty when
// history is reloaded from the session store)
toolName := tc.Name
if toolName == "" && tc.Function != nil {
toolName = tc.Function.Name
}
if strings.TrimSpace(toolName) == "" {
continue continue
} }
// Handle nil Arguments (GLM-4 may return null input) // Resolve arguments: prefer tc.Arguments, fallback to parsing
// tc.Function.Arguments
input := tc.Arguments input := tc.Arguments
if input == nil && tc.Function != nil && tc.Function.Arguments != "" {
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
input = map[string]any{}
}
}
// Handle nil Arguments (GLM-4 may return null input)
if input == nil { if input == nil {
input = map[string]any{} input = map[string]any{}
} }
@@ -246,7 +260,7 @@ func buildRequestBody(
toolUse := map[string]any{ toolUse := map[string]any{
"type": "tool_use", "type": "tool_use",
"id": tc.ID, "id": tc.ID,
"name": tc.Name, "name": toolName,
"input": input, "input": input,
} }
content = append(content, toolUse) content = append(content, toolUse)
@@ -614,6 +614,126 @@ func TestBuildRequestBody_UserToolResultsMerged(t *testing.T) {
} }
} }
// TestBuildRequestBody_ToolCallFunctionFallback verifies that tool calls whose
// runtime-only fields were lost in a JSON round-trip through the session store
// (ToolCall.Name/Arguments are json:"-"; only ToolCall.Function survives) fall
// back to Function.Name / Function.Arguments, so the tool_use block is still
// emitted and its tool_result pair stays intact. Without the fallback the
// tool_use is skipped and the orphaned tool_result 400s at the API
// ("unexpected tool_use_id found in tool_result blocks").
func TestBuildRequestBody_ToolCallFunctionFallback(t *testing.T) {
tests := []struct {
name string
toolCall ToolCall
wantSkipped bool
wantToolName string
wantInput map[string]any
}{
{
name: "deserialized history shape falls back to Function fields",
toolCall: ToolCall{
ID: "toolu-fallback-1",
Name: "",
Arguments: nil,
Function: &FunctionCall{Name: "x", Arguments: `{"a":1}`},
},
wantToolName: "x",
wantInput: map[string]any{"a": float64(1)},
},
{
name: "runtime shape with Name set and Function nil still works",
toolCall: ToolCall{
ID: "toolu-runtime-1",
Name: "y",
Arguments: map[string]any{"b": 2},
},
wantToolName: "y",
wantInput: map[string]any{"b": 2},
},
{
name: "both Name and Function.Name empty is skipped",
toolCall: ToolCall{
ID: "toolu-empty-1",
Name: "",
Function: &FunctionCall{Name: "", Arguments: `{"c":3}`},
},
wantSkipped: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
messages := []Message{
{Role: "user", Content: "run the tool"},
{Role: "assistant", Content: "", ToolCalls: []ToolCall{tt.toolCall}},
{Role: "tool", ToolCallID: tt.toolCall.ID, Content: "result"},
}
got, err := buildRequestBody(messages, nil, "test-model", map[string]any{"max_tokens": 8192})
if err != nil {
t.Fatalf("buildRequestBody() error: %v", err)
}
apiMessages := got["messages"].([]any)
if len(apiMessages) != 3 {
t.Fatalf("expected 3 API messages, got %d", len(apiMessages))
}
assistantMsg := apiMessages[1].(map[string]any)
content := assistantMsg["content"].([]any)
if tt.wantSkipped {
if len(content) != 0 {
t.Fatalf("assistant content = %#v, want empty (tool call skipped)", content)
}
// Note: matching current behavior, the orphaned tool_result is
// still emitted in the following user message even though its
// tool_use block was skipped.
toolResultMsg := apiMessages[2].(map[string]any)
blocks := toolResultMsg["content"].([]map[string]any)
if len(blocks) != 1 || blocks[0]["tool_use_id"] != tt.toolCall.ID {
t.Fatalf("orphaned tool_result = %#v, want single block with tool_use_id %q",
blocks, tt.toolCall.ID)
}
return
}
// (a) tool_use block emitted with resolved name, id, and parsed input.
if len(content) != 1 {
t.Fatalf("assistant content length = %d, want 1 tool_use block", len(content))
}
toolUse := content[0].(map[string]any)
if toolUse["type"] != "tool_use" {
t.Fatalf("block type = %v, want tool_use", toolUse["type"])
}
if toolUse["name"] != tt.wantToolName {
t.Errorf("tool_use name = %v, want %q", toolUse["name"], tt.wantToolName)
}
if toolUse["id"] != tt.toolCall.ID {
t.Errorf("tool_use id = %v, want %q", toolUse["id"], tt.toolCall.ID)
}
if !reflect.DeepEqual(toolUse["input"], tt.wantInput) {
t.Errorf("tool_use input = %#v, want %#v", toolUse["input"], tt.wantInput)
}
// (b) the following user message's tool_result references the same
// id as the tool_use block — the pair is intact.
toolResultMsg := apiMessages[2].(map[string]any)
if toolResultMsg["role"] != "user" {
t.Fatalf("message after assistant role = %v, want user", toolResultMsg["role"])
}
blocks := toolResultMsg["content"].([]map[string]any)
if len(blocks) != 1 {
t.Fatalf("tool_result blocks = %d, want 1", len(blocks))
}
if blocks[0]["tool_use_id"] != toolUse["id"] {
t.Errorf("tool_result tool_use_id = %v, want %v (paired with tool_use)",
blocks[0]["tool_use_id"], toolUse["id"])
}
})
}
}
// TestParseResponseBodyEdgeCases tests edge cases for parseResponseBody. // TestParseResponseBodyEdgeCases tests edge cases for parseResponseBody.
func TestParseResponseBodyEdgeCases(t *testing.T) { func TestParseResponseBodyEdgeCases(t *testing.T) {
tests := []struct { tests := []struct {
+142
View File
@@ -0,0 +1,142 @@
package providers
import (
"errors"
"regexp"
"strings"
"github.com/sipeed/picoclaw/pkg/providers/common"
)
type AuthErrorKind string
const (
AuthErrorInvalidAPIKey AuthErrorKind = "invalid_api_key"
AuthErrorMissingAPIKey AuthErrorKind = "missing_api_key"
AuthErrorExpiredToken AuthErrorKind = "expired_token"
AuthErrorGeneric AuthErrorKind = "auth"
)
var (
invalidAPIKeyPattern = regexp.MustCompile(
`(?i)\b(?:invalid|incorrect|malformed|wrong)[-_\s]+(?:api[-_\s]*)?key\b|\b(?:api[-_\s]*)?key[-_\s]+(?:is[-_\s]+)?(?:invalid|incorrect|malformed|wrong)\b|\binvalid[-_]?api[-_]?key\b`,
)
missingAPIKeyPattern = regexp.MustCompile(
`(?i)\b(?:missing|no|not[-_\s]+configured)[-_\s]+(?:api[-_\s]*)?key\b|\bno[-_\s]+credentials[-_\s]+found\b|\bapi[-_\s]+key[-_\s]+not[-_\s]+configured\b`,
)
expiredTokenPattern = regexp.MustCompile(
`(?i)\b(?:oauth[-_\s]+token[-_\s]+refresh[-_\s]+failed|re-authenticate|token[-_\s]+(?:has[-_\s]+)?expired|expired[-_\s]+token)\b`,
)
genericAuthPattern = regexp.MustCompile(
`(?i)\b(?:unauthorized|forbidden|authentication[-_\s]+(?:failed|required)|access[-_\s]+denied)\b`,
)
)
func ClassifyAuthError(err error) (AuthErrorKind, bool) {
if err == nil {
return "", false
}
var exhausted *FallbackExhaustedError
if errors.As(err, &exhausted) && exhausted != nil {
return classifyFallbackExhaustedAuthError(exhausted)
}
msg := authErrorText(err)
if missingAPIKeyPattern.MatchString(msg) {
return AuthErrorMissingAPIKey, true
}
if expiredTokenPattern.MatchString(msg) {
return AuthErrorExpiredToken, true
}
if invalidAPIKeyPattern.MatchString(msg) {
return AuthErrorInvalidAPIKey, true
}
if hasStructuredAuthError(err) || genericAuthPattern.MatchString(msg) {
return AuthErrorGeneric, true
}
return "", false
}
func authErrorText(err error) string {
var parts []string
if err != nil {
parts = append(parts, err.Error())
}
var httpErr *common.HTTPError
if errors.As(err, &httpErr) && httpErr != nil {
parts = append(parts, httpErr.BodyPreview)
}
return strings.Join(parts, "\n")
}
func hasStructuredAuthError(err error) bool {
var failErr *FailoverError
if errors.As(err, &failErr) && failErr != nil && failErr.Reason == FailoverAuth {
return true
}
var httpErr *common.HTTPError
if errors.As(err, &httpErr) && httpErr != nil {
return httpErr.StatusCode == 401 || httpErr.StatusCode == 403
}
return false
}
func classifyFallbackExhaustedAuthError(err *FallbackExhaustedError) (AuthErrorKind, bool) {
if err == nil {
return "", false
}
var authMessages []string
nonSkipped := 0
for _, attempt := range err.Attempts {
if attempt.Skipped {
continue
}
nonSkipped++
if !attemptIsAuthFailure(attempt) {
return "", false
}
if attempt.Error != nil {
authMessages = append(authMessages, authErrorText(attempt.Error))
}
}
if nonSkipped == 0 {
return "", false
}
msg := strings.Join(authMessages, "\n")
if missingAPIKeyPattern.MatchString(msg) {
return AuthErrorMissingAPIKey, true
}
if expiredTokenPattern.MatchString(msg) {
return AuthErrorExpiredToken, true
}
if invalidAPIKeyPattern.MatchString(msg) {
return AuthErrorInvalidAPIKey, true
}
return AuthErrorGeneric, true
}
func attemptIsAuthFailure(attempt FallbackAttempt) bool {
if attempt.Reason == FailoverAuth {
return true
}
if attempt.Error == nil {
return false
}
var failErr *FailoverError
if errors.As(attempt.Error, &failErr) && failErr != nil && failErr.Reason == FailoverAuth {
return true
}
var httpErr *common.HTTPError
if errors.As(attempt.Error, &httpErr) && httpErr != nil {
return httpErr.StatusCode == 401 || httpErr.StatusCode == 403
}
return false
}
+133
View File
@@ -0,0 +1,133 @@
package providers
import (
"errors"
"fmt"
"testing"
"github.com/sipeed/picoclaw/pkg/providers/common"
)
func TestClassifyError_HTTPErrorStatus(t *testing.T) {
err := fmt.Errorf("provider request: %w", &common.HTTPError{
StatusCode: httpStatusUnauthorized,
BodyPreview: `{"error":"unauthorized"}`,
ContentType: "application/json",
APIBase: "https://api.example.com",
})
result := ClassifyError(err, "openai", "gpt-4")
if result == nil {
t.Fatal("expected classified error")
}
if result.Reason != FailoverAuth {
t.Fatalf("reason = %q, want %q", result.Reason, FailoverAuth)
}
if result.Status != httpStatusUnauthorized {
t.Fatalf("status = %d, want %d", result.Status, httpStatusUnauthorized)
}
}
func TestClassifyAuthError(t *testing.T) {
tests := []struct {
name string
err error
want AuthErrorKind
}{
{
name: "invalid api key",
err: errors.New(
`API request failed: Status: 401 Body: {"error":{"message":"Incorrect API key provided"}}`,
),
want: AuthErrorInvalidAPIKey,
},
{
name: "missing api key",
err: errors.New("API key not configured"),
want: AuthErrorMissingAPIKey,
},
{
name: "expired token",
err: errors.New("oauth token refresh failed: token has expired"),
want: AuthErrorExpiredToken,
},
{
name: "structured generic auth",
err: &common.HTTPError{
StatusCode: httpStatusUnauthorized,
BodyPreview: `{"error":"unauthorized"}`,
ContentType: "application/json",
APIBase: "https://api.example.com",
},
want: AuthErrorGeneric,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := ClassifyAuthError(tt.err)
if !ok {
t.Fatal("expected auth classification")
}
if got != tt.want {
t.Fatalf("kind = %q, want %q", got, tt.want)
}
})
}
}
func TestClassifyAuthError_FallbackExhaustedAllAuth(t *testing.T) {
err := &FallbackExhaustedError{
Attempts: []FallbackAttempt{
{
Reason: FailoverAuth,
Error: &FailoverError{
Reason: FailoverAuth,
Wrapped: errors.New("invalid api key"),
},
},
{
Reason: FailoverAuth,
Error: &FailoverError{
Reason: FailoverAuth,
Wrapped: errors.New("unauthorized"),
},
},
},
}
got, ok := ClassifyAuthError(err)
if !ok {
t.Fatal("expected auth classification")
}
if got != AuthErrorInvalidAPIKey {
t.Fatalf("kind = %q, want %q", got, AuthErrorInvalidAPIKey)
}
}
func TestClassifyAuthError_FallbackExhaustedMixedFailures(t *testing.T) {
err := &FallbackExhaustedError{
Attempts: []FallbackAttempt{
{
Reason: FailoverAuth,
Error: &FailoverError{
Reason: FailoverAuth,
Wrapped: errors.New("invalid api key"),
},
},
{
Reason: FailoverRateLimit,
Error: &FailoverError{
Reason: FailoverRateLimit,
Wrapped: errors.New("rate limit exceeded"),
},
},
},
}
if got, ok := ClassifyAuthError(err); ok {
t.Fatalf("kind = %q, want no auth classification for mixed failures", got)
}
}
const httpStatusUnauthorized = 401
+43 -13
View File
@@ -371,6 +371,36 @@ func DecodeToolCallArguments(raw json.RawMessage, name string) map[string]any {
// --- HTTP response helpers --- // --- HTTP response helpers ---
// HTTPError captures structured details for non-OK provider HTTP responses.
// Error preserves the previous user/log-facing text format for compatibility.
type HTTPError struct {
StatusCode int
BodyPreview string
ContentType string
APIBase string
IsHTML bool
}
func (e *HTTPError) Error() string {
if e == nil {
return "API request failed"
}
if e.IsHTML {
return fmt.Sprintf(
"API request failed: %s returned HTML instead of JSON (content-type: %s); check api_base or proxy configuration.\n Status: %d\n Body: %s",
e.APIBase,
e.ContentType,
e.StatusCode,
e.BodyPreview,
)
}
return fmt.Sprintf(
"API request failed:\n Status: %d\n Body: %s",
e.StatusCode,
e.BodyPreview,
)
}
// HandleErrorResponse reads a non-200 response body and returns an appropriate error. // HandleErrorResponse reads a non-200 response body and returns an appropriate error.
func HandleErrorResponse(resp *http.Response, apiBase string) error { func HandleErrorResponse(resp *http.Response, apiBase string) error {
contentType := resp.Header.Get("Content-Type") contentType := resp.Header.Get("Content-Type")
@@ -381,11 +411,12 @@ func HandleErrorResponse(resp *http.Response, apiBase string) error {
if LooksLikeHTML(body, contentType) { if LooksLikeHTML(body, contentType) {
return WrapHTMLResponseError(resp.StatusCode, body, contentType, apiBase) return WrapHTMLResponseError(resp.StatusCode, body, contentType, apiBase)
} }
return fmt.Errorf( return &HTTPError{
"API request failed:\n Status: %d\n Body: %s", StatusCode: resp.StatusCode,
resp.StatusCode, BodyPreview: ResponsePreview(body, 128),
ResponsePreview(body, 128), ContentType: contentType,
) APIBase: apiBase,
}
} }
// ReadAndParseResponse peeks at the response body to detect HTML errors, // ReadAndParseResponse peeks at the response body to detect HTML errors,
@@ -422,14 +453,13 @@ func LooksLikeHTML(body []byte, contentType string) bool {
// WrapHTMLResponseError creates a descriptive error for HTML responses. // WrapHTMLResponseError creates a descriptive error for HTML responses.
func WrapHTMLResponseError(statusCode int, body []byte, contentType, apiBase string) error { func WrapHTMLResponseError(statusCode int, body []byte, contentType, apiBase string) error {
respPreview := ResponsePreview(body, 128) return &HTTPError{
return fmt.Errorf( StatusCode: statusCode,
"API request failed: %s returned HTML instead of JSON (content-type: %s); check api_base or proxy configuration.\n Status: %d\n Body: %s", BodyPreview: ResponsePreview(body, 128),
apiBase, ContentType: contentType,
contentType, APIBase: apiBase,
statusCode, IsHTML: true,
respPreview, }
)
} }
// ResponsePreview returns a truncated preview of response body for error messages. // ResponsePreview returns a truncated preview of response body for error messages.
+59
View File
@@ -0,0 +1,59 @@
package common
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHandleErrorResponse_ReturnsHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
}))
defer server.Close()
resp, err := http.Get(server.URL)
if err != nil {
t.Fatalf("http.Get() error = %v", err)
}
defer resp.Body.Close()
err = HandleErrorResponse(resp, server.URL)
var httpErr *HTTPError
if !errors.As(err, &httpErr) {
t.Fatalf("HandleErrorResponse() error = %T, want *HTTPError", err)
}
if httpErr.StatusCode != http.StatusUnauthorized {
t.Fatalf("StatusCode = %d, want %d", httpErr.StatusCode, http.StatusUnauthorized)
}
if httpErr.BodyPreview != `{"error":"unauthorized"}` {
t.Fatalf("BodyPreview = %q", httpErr.BodyPreview)
}
if !strings.Contains(err.Error(), "Status: 401") {
t.Fatalf("Error() should preserve status text, got %q", err.Error())
}
}
func TestWrapHTMLResponseError_ReturnsHTTPError(t *testing.T) {
err := WrapHTMLResponseError(
http.StatusBadGateway,
[]byte("<html>bad</html>"),
"text/html",
"https://api.example.com",
)
var httpErr *HTTPError
if !errors.As(err, &httpErr) {
t.Fatalf("WrapHTMLResponseError() error = %T, want *HTTPError", err)
}
if !httpErr.IsHTML {
t.Fatal("expected IsHTML")
}
if !strings.Contains(err.Error(), "HTML instead of JSON") {
t.Fatalf("Error() should preserve HTML message, got %q", err.Error())
}
}
+16
View File
@@ -8,6 +8,8 @@ import (
"regexp" "regexp"
"strings" "strings"
"syscall" "syscall"
"github.com/sipeed/picoclaw/pkg/providers/common"
) )
// Common patterns in Go HTTP error messages // Common patterns in Go HTTP error messages
@@ -88,6 +90,8 @@ var (
} }
authPatterns = []errorPattern{ authPatterns = []errorPattern{
rxp(`\b(?:invalid|incorrect|malformed|wrong)[-_\s]+(?:api[-_\s]*)?key\b`),
rxp(`\b(?:api[-_\s]*)?key[-_\s]+(?:is[-_\s]+)?(?:invalid|incorrect|malformed|wrong)\b`),
rxp(`invalid[_ ]?api[_ ]?key`), rxp(`invalid[_ ]?api[_ ]?key`),
substr("incorrect api key"), substr("incorrect api key"),
substr("invalid token"), substr("invalid token"),
@@ -188,6 +192,18 @@ func ClassifyError(err error, provider, model string) *FailoverError {
} }
// Try HTTP status code extraction first. // Try HTTP status code extraction first.
var httpErr *common.HTTPError
if errors.As(err, &httpErr) && httpErr != nil {
if reason := classifyByStatus(httpErr.StatusCode); reason != "" {
return &FailoverError{
Reason: reason,
Provider: provider,
Model: model,
Status: httpErr.StatusCode,
Wrapped: err,
}
}
}
if status := extractHTTPStatus(msg); status > 0 { if status := extractHTTPStatus(msg); status > 0 {
if reason := classifyByStatus(status); reason != "" { if reason := classifyByStatus(status); reason != "" {
return &FailoverError{ return &FailoverError{
+1 -1
View File
@@ -201,7 +201,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
return finalizeProviderFromConfig(provider, modelID, cfg) return finalizeProviderFromConfig(provider, modelID, cfg)
case "litellm", "lmstudio", "gpt4free", "openrouter", "groq", "zhipu", "nvidia", "venice", case "litellm", "lmstudio", "gpt4free", "openrouter", "groq", "zhipu", "nvidia", "venice",
"ollama", "moonshot", "shengsuanyun", "siliconflow", "deepseek", "cerebras", "nearai", "ollama", "moonshot", "shengsuanyun", "siliconflow", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen-portal", "qwen-intl", "qwen-us", "mistral", "vivgrid", "volcengine", "vllm", "qwen-portal", "qwen-intl", "qwen-us", "mistral",
"avian", "longcat", "modelscope", "novita", "alibaba-coding", "zai", "mimo": "avian", "longcat", "modelscope", "novita", "alibaba-coding", "zai", "mimo":
// All other OpenAI-compatible HTTP providers // All other OpenAI-compatible HTTP providers
+48
View File
@@ -223,6 +223,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
}{ }{
{"openai", "openai"}, {"openai", "openai"},
{"venice", "venice"}, {"venice", "venice"},
{"nearai", "nearai"},
{"groq", "groq"}, {"groq", "groq"},
{"novita", "novita"}, {"novita", "novita"},
{"openrouter", "openrouter"}, {"openrouter", "openrouter"},
@@ -288,6 +289,15 @@ func TestGetDefaultAPIBase_Venice(t *testing.T) {
} }
} }
func TestGetDefaultAPIBase_NearAI(t *testing.T) {
if got := getDefaultAPIBase("nearai"); got != "https://cloud-api.near.ai/v1" {
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "nearai", got, "https://cloud-api.near.ai/v1")
}
if got := getDefaultAPIBase("near-ai"); got != "https://cloud-api.near.ai/v1" {
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "near-ai", got, "https://cloud-api.near.ai/v1")
}
}
func TestGetDefaultAPIBase_SiliconFlow(t *testing.T) { func TestGetDefaultAPIBase_SiliconFlow(t *testing.T) {
if got := getDefaultAPIBase("siliconflow"); got != "https://api.siliconflow.cn/v1" { if got := getDefaultAPIBase("siliconflow"); got != "https://api.siliconflow.cn/v1" {
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "siliconflow", got, "https://api.siliconflow.cn/v1") t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "siliconflow", got, "https://api.siliconflow.cn/v1")
@@ -525,6 +535,28 @@ func TestCreateProviderFromConfig_Venice(t *testing.T) {
} }
} }
func TestCreateProviderFromConfig_NearAI(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-nearai",
Model: "nearai/zai-org/GLM-5.1-FP8",
}
cfg.SetAPIKey("test-key")
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
t.Fatalf("CreateProviderFromConfig() error = %v", err)
}
if provider == nil {
t.Fatal("CreateProviderFromConfig() returned nil provider")
}
if modelID != "zai-org/GLM-5.1-FP8" {
t.Errorf("modelID = %q, want %q", modelID, "zai-org/GLM-5.1-FP8")
}
if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("expected *HTTPProvider, got %T", provider)
}
}
func TestCreateProviderFromConfig_SiliconFlow(t *testing.T) { func TestCreateProviderFromConfig_SiliconFlow(t *testing.T) {
cfg := &config.ModelConfig{ cfg := &config.ModelConfig{
ModelName: "test-siliconflow", ModelName: "test-siliconflow",
@@ -1076,6 +1108,22 @@ func TestModelProviderOptions(t *testing.T) {
"https://api.siliconflow.cn/v1", "https://api.siliconflow.cn/v1",
) )
} }
if option, ok := seen["nearai"]; !ok {
t.Fatal("nearai option missing")
} else {
if option.DisplayName != "NEAR AI Cloud" {
t.Fatalf("nearai display_name = %q, want %q", option.DisplayName, "NEAR AI Cloud")
}
if option.DefaultAPIBase != "https://cloud-api.near.ai/v1" {
t.Fatalf("nearai default_api_base = %q, want %q", option.DefaultAPIBase, "https://cloud-api.near.ai/v1")
}
if !option.SupportsFetch {
t.Fatal("nearai should support upstream model listing")
}
if len(option.CommonModels) == 0 {
t.Fatal("nearai common_models should not be empty")
}
}
if option, ok := seen["anthropic"]; !ok { if option, ok := seen["anthropic"]; !ok {
t.Fatal("anthropic option missing") t.Fatal("anthropic option missing")
} else if option.DefaultAPIBase != "https://api.anthropic.com/v1" { } else if option.DefaultAPIBase != "https://api.anthropic.com/v1" {
+1
View File
@@ -285,6 +285,7 @@ func (p *GeminiProvider) buildRequestBody(
} }
if thoughtSignature != "" { if thoughtSignature != "" {
part.ThoughtSignature = thoughtSignature part.ThoughtSignature = thoughtSignature
part.ThoughtSignatureSnake = thoughtSignature
} }
content.Parts = append(content.Parts, part) content.Parts = append(content.Parts, part)
} }
@@ -453,7 +453,7 @@ func TestGeminiProvider_ChatStreamReturnsErrorOnInvalidDataFrame(t *testing.T) {
} }
} }
func TestGeminiProvider_BuildRequestBody_UsesCamelCaseThoughtSignatureOnly(t *testing.T) { func TestGeminiProvider_BuildRequestBody_SetsBothThoughtSignatureFormats(t *testing.T) {
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
body := provider.buildRequestBody( body := provider.buildRequestBody(
@@ -484,8 +484,12 @@ func TestGeminiProvider_BuildRequestBody_UsesCamelCaseThoughtSignatureOnly(t *te
if !strings.Contains(jsonBody, `"thoughtSignature":"sig-1"`) { if !strings.Contains(jsonBody, `"thoughtSignature":"sig-1"`) {
t.Fatalf("request body = %s, expected camelCase thoughtSignature", jsonBody) t.Fatalf("request body = %s, expected camelCase thoughtSignature", jsonBody)
} }
if strings.Contains(jsonBody, `"thought_signature"`) { if !strings.Contains(jsonBody, `"thought_signature":"sig-1"`) {
t.Fatalf("request body = %s, unexpected snake_case thought_signature", jsonBody) t.Fatalf(
"request body = %s, expected snake_case thought_signature for "+
"Gemini 3.5 Flash Agentic compatibility",
jsonBody,
)
} }
} }
+16 -1
View File
@@ -53,6 +53,7 @@ const (
var stripModelPrefixProviders = map[string]struct{}{ var stripModelPrefixProviders = map[string]struct{}{
"litellm": {}, "litellm": {},
"nearai": {},
"venice": {}, "venice": {},
"moonshot": {}, "moonshot": {},
"nvidia": {}, "nvidia": {},
@@ -153,7 +154,21 @@ func (p *Provider) buildRequestBody(
} }
// When fallback uses a different provider (e.g. DeepSeek), that provider must not inject web_search_preview. // When fallback uses a different provider (e.g. DeepSeek), that provider must not inject web_search_preview.
nativeSearch, _ := options["native_search"].(bool) nativeSearch, ok := options["native_search"].(bool)
if !ok {
// If the option is present but not a bool, log a warning and
// treat it as false — web_search_preview must not be injected
// when the caller cannot express a well-typed intent.
if _, present := options["native_search"]; present {
logger.WarnCF(
"provider.openai_compat",
"native_search option has unexpected type, ignoring",
map[string]any{
"type": fmt.Sprintf("%T", options["native_search"]),
},
)
}
}
nativeSearch = nativeSearch && isNativeSearchHost(p.apiBase) nativeSearch = nativeSearch && isNativeSearchHost(p.apiBase)
if len(tools) > 0 || nativeSearch { if len(tools) > 0 || nativeSearch {
requestBody["tools"] = buildToolsList(tools, nativeSearch) requestBody["tools"] = buildToolsList(tools, nativeSearch)
+18
View File
@@ -398,6 +398,24 @@ var modelProviderOptionsByName = map[string]ModelProviderOption{
Priority: 45, Priority: 45,
httpAPI: true, httpAPI: true,
}, },
"nearai": {
ID: "nearai",
DisplayName: "NEAR AI Cloud",
Domain: "near.ai",
DefaultAPIBase: "https://cloud-api.near.ai/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 44.5,
CommonModels: []string{
"zai-org/GLM-5.1-FP8",
"openai/gpt-oss-120b",
"Qwen/Qwen3-30B-A3B-Instruct-2507",
"Qwen/Qwen3.6-35B-A3B-FP8",
},
Aliases: []string{"near-ai", "near-ai-cloud"},
httpAPI: true,
},
"shengsuanyun": { "shengsuanyun": {
ID: "shengsuanyun", ID: "shengsuanyun",
DisplayName: "ShengsuanYun", DisplayName: "ShengsuanYun",
+4 -1
View File
@@ -124,6 +124,9 @@ func (t *ExpandTool) Execute(ctx context.Context, args map[string]any) *tools.To
"tokenCount": result.TokenCount, "tokenCount": result.TokenCount,
"messages": messages, "messages": messages,
} }
data, _ := json.Marshal(output) data, err := json.Marshal(output)
if err != nil {
return tools.ErrorResult(fmt.Sprintf("failed to marshal expand result: %v", err))
}
return tools.NewToolResult(string(data)) return tools.NewToolResult(string(data))
} }
+4 -1
View File
@@ -167,6 +167,9 @@ func (t *GrepTool) Execute(ctx context.Context, args map[string]any) *tools.Tool
output["hint"] = result.Hint output["hint"] = result.Hint
} }
data, _ := json.Marshal(output) data, err := json.Marshal(output)
if err != nil {
return tools.ErrorResult(fmt.Sprintf("failed to marshal grep result: %v", err))
}
return tools.NewToolResult(string(data)) return tools.NewToolResult(string(data))
} }
+79 -26
View File
@@ -26,12 +26,13 @@ type JobExecutor interface {
// CronTool provides scheduling capabilities for the agent // CronTool provides scheduling capabilities for the agent
type CronTool struct { type CronTool struct {
cronService *cron.CronService cronService *cron.CronService
executor JobExecutor executor JobExecutor
msgBus *bus.MessageBus msgBus *bus.MessageBus
execTool *ExecTool execTool *ExecTool
allowCommand bool allowCommand bool
execEnabled bool execEnabled bool
commandAllowedRemotes []string
} }
// NewCronTool creates a new CronTool // NewCronTool creates a new CronTool
@@ -42,9 +43,11 @@ func NewCronTool(
) (*CronTool, error) { ) (*CronTool, error) {
allowCommand := true allowCommand := true
execEnabled := true execEnabled := true
var commandAllowedRemotes []string
if config != nil { if config != nil {
allowCommand = config.Tools.Cron.AllowCommand allowCommand = config.Tools.Cron.AllowCommand
execEnabled = config.Tools.Exec.Enabled execEnabled = config.Tools.Exec.Enabled
commandAllowedRemotes = config.Tools.Cron.CommandAllowedRemotes
} }
var execTool *ExecTool var execTool *ExecTool
@@ -60,12 +63,13 @@ func NewCronTool(
execTool.SetTimeout(execTimeout) execTool.SetTimeout(execTimeout)
} }
return &CronTool{ return &CronTool{
cronService: cronService, cronService: cronService,
executor: executor, executor: executor,
msgBus: msgBus, msgBus: msgBus,
execTool: execTool, execTool: execTool,
allowCommand: allowCommand, allowCommand: allowCommand,
execEnabled: execEnabled, execEnabled: execEnabled,
commandAllowedRemotes: commandAllowedRemotes,
}, nil }, nil
} }
@@ -150,11 +154,11 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult
case "update": case "update":
return t.updateJob(ctx, args) return t.updateJob(ctx, args)
case "remove": case "remove":
return t.removeJob(args) return t.removeJob(ctx, args)
case "enable": case "enable":
return t.enableJob(args, true) return t.enableJob(ctx, args, true)
case "disable": case "disable":
return t.enableJob(args, false) return t.enableJob(ctx, args, false)
default: default:
return ErrorResult(fmt.Sprintf("unknown action: %s", action)) return ErrorResult(fmt.Sprintf("unknown action: %s", action))
} }
@@ -217,8 +221,10 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
if !t.execEnabled { if !t.execEnabled {
return ErrorResult("command execution is disabled") return ErrorResult("command execution is disabled")
} }
if !constants.IsInternalChannel(channel) { if !constants.IsInternalChannel(channel) && !isCommandAllowedRemote(channel, chatID, t.commandAllowedRemotes) {
return ErrorResult("scheduling command execution is restricted to internal channels") return ErrorResult(
"scheduling command execution is restricted to internal channels or configured remote channels",
)
} }
if !t.allowCommand && !commandConfirm { if !t.allowCommand && !commandConfirm {
return ErrorResult("command_confirm=true is required when allow_command is disabled") return ErrorResult("command_confirm=true is required when allow_command is disabled")
@@ -371,12 +377,20 @@ func (t *CronTool) updateJob(ctx context.Context, args map[string]any) *ToolResu
return SilentResult(fmt.Sprintf("Cron job updated:\n%s", formatCronJobJSON(updated))) return SilentResult(fmt.Sprintf("Cron job updated:\n%s", formatCronJobJSON(updated)))
} }
func (t *CronTool) removeJob(args map[string]any) *ToolResult { func (t *CronTool) removeJob(ctx context.Context, args map[string]any) *ToolResult {
jobID, ok := args["job_id"].(string) jobID, ok := args["job_id"].(string)
if !ok || jobID == "" { if !ok || jobID == "" {
return ErrorResult("job_id is required for remove") return ErrorResult("job_id is required for remove")
} }
job, ok := t.cronService.GetJob(jobID)
if !ok {
return ErrorResult(fmt.Sprintf("Job %s not found", jobID))
}
if !t.canAccessJob(ctx, job) {
return ErrorResult(fmt.Sprintf("Job %s is not accessible from this channel", jobID))
}
if t.cronService.RemoveJob(jobID) { if t.cronService.RemoveJob(jobID) {
return SilentResult(fmt.Sprintf("Cron job removed: %s", jobID)) return SilentResult(fmt.Sprintf("Cron job removed: %s", jobID))
} }
@@ -483,11 +497,15 @@ func positiveSeconds(args map[string]any, key string) (int64, *ToolResult) {
} }
func (t *CronTool) validateCommandMutation(ctx context.Context, args map[string]any) *ToolResult { func (t *CronTool) validateCommandMutation(ctx context.Context, args map[string]any) *ToolResult {
channel := ToolChannel(ctx)
chatID := ToolChatID(ctx)
if !t.execEnabled { if !t.execEnabled {
return ErrorResult("command execution is disabled") return ErrorResult("command execution is disabled")
} }
if !constants.IsInternalChannel(ToolChannel(ctx)) { if !constants.IsInternalChannel(channel) && !isCommandAllowedRemote(channel, chatID, t.commandAllowedRemotes) {
return ErrorResult("updating command execution is restricted to internal channels") return ErrorResult(
"updating command execution is restricted to internal channels or configured remote channels",
)
} }
commandConfirm, _ := args["command_confirm"].(bool) commandConfirm, _ := args["command_confirm"].(bool)
if !t.allowCommand && !commandConfirm { if !t.allowCommand && !commandConfirm {
@@ -496,19 +514,46 @@ func (t *CronTool) validateCommandMutation(ctx context.Context, args map[string]
return nil return nil
} }
func isCommandAllowedRemote(channel, chatID string, allowed []string) bool {
if channel == "" {
return false
}
target := channel
if chatID != "" {
target = channel + ":" + chatID
}
for _, entry := range allowed {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if entry == "*" || entry == channel || entry == target {
return true
}
}
return false
}
func (t *CronTool) canAccessJob(ctx context.Context, job *cron.CronJob) bool { func (t *CronTool) canAccessJob(ctx context.Context, job *cron.CronJob) bool {
channel := ToolChannel(ctx) channel := ToolChannel(ctx)
if constants.IsInternalChannel(channel) { if constants.IsInternalChannel(channel) {
return true return true
} }
chatID := ToolChatID(ctx) chatID := ToolChatID(ctx)
if channel == "" || chatID == "" { if channel == "" || chatID == "" {
return false return false
} }
if job.Payload.Command != "" { if job.Payload.Channel != channel || job.Payload.To != chatID {
return false return false
} }
return job.Payload.Channel == channel && job.Payload.To == chatID if job.Payload.Command != "" {
return isCommandAllowedRemote(channel, chatID, t.commandAllowedRemotes)
}
return true
} }
func formatCronJobJSON(job *cron.CronJob) string { func formatCronJobJSON(job *cron.CronJob) string {
@@ -519,14 +564,22 @@ func formatCronJobJSON(job *cron.CronJob) string {
return string(data) return string(data)
} }
func (t *CronTool) enableJob(args map[string]any, enable bool) *ToolResult { func (t *CronTool) enableJob(ctx context.Context, args map[string]any, enable bool) *ToolResult {
jobID, ok := args["job_id"].(string) jobID, ok := args["job_id"].(string)
if !ok || jobID == "" { if !ok || jobID == "" {
return ErrorResult("job_id is required for enable/disable") return ErrorResult("job_id is required for enable/disable")
} }
job := t.cronService.EnableJob(jobID, enable) job, ok := t.cronService.GetJob(jobID)
if job == nil { if !ok {
return ErrorResult(fmt.Sprintf("Job %s not found", jobID))
}
if !t.canAccessJob(ctx, job) {
return ErrorResult(fmt.Sprintf("Job %s is not accessible from this channel", jobID))
}
updatedJob := t.cronService.EnableJob(jobID, enable)
if updatedJob == nil {
return ErrorResult(fmt.Sprintf("Job %s not found", jobID)) return ErrorResult(fmt.Sprintf("Job %s not found", jobID))
} }
@@ -534,7 +587,7 @@ func (t *CronTool) enableJob(args map[string]any, enable bool) *ToolResult {
if !enable { if !enable {
status = "disabled" status = "disabled"
} }
return SilentResult(fmt.Sprintf("Cron job '%s' %s", job.Name, status)) return SilentResult(fmt.Sprintf("Cron job '%s' %s", updatedJob.Name, status))
} }
// ExecuteJob executes a cron job through the agent // ExecuteJob executes a cron job through the agent
+455 -3
View File
@@ -87,7 +87,29 @@ func parseCronJobResult(t *testing.T, result *ToolResult) cron.CronJob {
return job return job
} }
// TestCronTool_CommandBlockedFromRemoteChannel verifies command scheduling is restricted to internal channels func addTestCronJob(t *testing.T, tool *CronTool, name, channel, chatID, command string) *cron.CronJob {
t.Helper()
everyMS := int64(60_000)
job, err := tool.cronService.AddJob(
name,
cron.CronSchedule{Kind: "every", EveryMS: &everyMS},
name+" message",
channel,
chatID,
)
if err != nil {
t.Fatalf("AddJob() error: %v", err)
}
if command != "" {
job.Payload.Command = command
if err := tool.cronService.UpdateJob(job); err != nil {
t.Fatalf("UpdateJob() error: %v", err)
}
}
return job
}
// TestCronTool_CommandBlockedFromRemoteChannel verifies command scheduling is restricted by default.
func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) { func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) {
tool := newTestCronTool(t) tool := newTestCronTool(t)
ctx := WithToolContext(context.Background(), "telegram", "chat-1") ctx := WithToolContext(context.Background(), "telegram", "chat-1")
@@ -102,8 +124,148 @@ func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) {
if !result.IsError { if !result.IsError {
t.Fatal("expected command scheduling to be blocked from remote channel") t.Fatal("expected command scheduling to be blocked from remote channel")
} }
if !strings.Contains(result.ForLLM, "restricted to internal channels") { if !strings.Contains(result.ForLLM, "restricted to internal channels or configured remote channels") {
t.Errorf("expected 'restricted to internal channels', got: %s", result.ForLLM) t.Errorf("expected remote restriction message, got: %s", result.ForLLM)
}
}
func TestCronTool_CommandAllowedFromRemoteChannelAllowlist(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram"}
tool := newTestCronToolWithConfig(t, cfg)
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"at_seconds": float64(60),
})
if result.IsError {
t.Fatalf("expected command scheduling from allowed remote channel to succeed, got: %s", result.ForLLM)
}
}
func TestCronTool_CommandAllowedFromRemoteChatIDAllowlist(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{" telegram:1234567890 "}
tool := newTestCronToolWithConfig(t, cfg)
ctx := WithToolContext(context.Background(), "telegram", "1234567890")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"at_seconds": float64(60),
})
if result.IsError {
t.Fatalf("expected command scheduling from allowed remote chat to succeed, got: %s", result.ForLLM)
}
}
func TestCronTool_CommandAllowedFromRemoteWildcardAllowlist(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"*"}
tool := newTestCronToolWithConfig(t, cfg)
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"at_seconds": float64(60),
})
if result.IsError {
t.Fatalf("expected wildcard allowlist to allow remote command scheduling, got: %s", result.ForLLM)
}
}
func TestCronTool_CommandAllowedRemoteWildcardRequiresNonEmptyChannel(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"*"}
tool := newTestCronToolWithConfig(t, cfg)
ctx := WithToolContext(context.Background(), "", "chat-1")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"at_seconds": float64(60),
})
if !result.IsError {
t.Fatal("expected missing channel to remain blocked even with wildcard allowlist")
}
if !strings.Contains(result.ForLLM, "no session context") {
t.Errorf("expected session context error, got: %s", result.ForLLM)
}
}
func TestCronTool_CommandBlockedFromDifferentRemoteChatID(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram:1234567890"}
tool := newTestCronToolWithConfig(t, cfg)
ctx := WithToolContext(context.Background(), "telegram", "other-chat")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"command_confirm": true,
"at_seconds": float64(60),
})
if !result.IsError {
t.Fatal("expected command scheduling from non-allowlisted remote chat to fail")
}
if !strings.Contains(result.ForLLM, "restricted to internal channels or configured remote channels") {
t.Errorf("expected remote restriction message, got: %s", result.ForLLM)
}
}
func TestCronTool_CommandAllowedRemoteRequiresConfirmWhenAllowCommandDisabled(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.AllowCommand = false
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram"}
tool := newTestCronToolWithConfig(t, cfg)
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"at_seconds": float64(60),
})
if !result.IsError {
t.Fatal("expected allowlisted remote command scheduling to require confirm when allow_command is disabled")
}
if !strings.Contains(result.ForLLM, "command_confirm=true") {
t.Errorf("expected command_confirm requirement message, got: %s", result.ForLLM)
}
}
func TestCronTool_AllowCommandDoesNotBypassRemoteAllowlist(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.AllowCommand = true
tool := newTestCronToolWithConfig(t, cfg)
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"at_seconds": float64(60),
})
if !result.IsError {
t.Fatal("expected allow_command=true not to bypass remote allowlist")
}
if !strings.Contains(result.ForLLM, "restricted to internal channels or configured remote channels") {
t.Errorf("expected remote restriction message, got: %s", result.ForLLM)
} }
} }
@@ -554,6 +716,296 @@ func TestCronTool_RemoteCannotAccessCommandJob(t *testing.T) {
} }
} }
func TestCronTool_AllowlistedRemoteCanAccessOwnCommandJob(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram:chat-1"}
tool := newTestCronToolWithConfig(t, cfg)
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
listResult := tool.Execute(ctx, map[string]any{"action": "list"})
if listResult.IsError || !strings.Contains(listResult.ForLLM, job.ID) {
t.Fatalf("expected list to include own command job, got: %+v", listResult)
}
getResult := tool.Execute(ctx, map[string]any{"action": "get", "job_id": job.ID})
if getResult.IsError {
t.Fatalf("expected get to access own command job, got: %s", getResult.ForLLM)
}
got := parseCronJobResult(t, getResult)
if got.ID != job.ID || got.Payload.Command != "df -h" {
t.Fatalf("get returned wrong command job: %+v", got)
}
updateResult := tool.Execute(ctx, map[string]any{
"action": "update",
"job_id": job.ID,
"message": "updated command description",
})
if updateResult.IsError {
t.Fatalf("expected update to access own command job, got: %s", updateResult.ForLLM)
}
updated, _ := tool.cronService.GetJob(job.ID)
if updated.Payload.Message != "updated command description" || updated.Payload.Command != "df -h" {
t.Fatalf("update returned wrong command payload: %+v", updated.Payload)
}
}
func TestCronTool_AllowlistedRemoteCannotAccessOtherChatCommandJob(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram"}
tool := newTestCronToolWithConfig(t, cfg)
job := addTestCronJob(t, tool, "command", "telegram", "chat-2", "df -h")
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
listResult := tool.Execute(ctx, map[string]any{"action": "list"})
if listResult.IsError || strings.Contains(listResult.ForLLM, job.ID) {
t.Fatalf("expected list to hide other chat command job, got: %+v", listResult)
}
for _, action := range []string{"get", "update"} {
args := map[string]any{"action": action, "job_id": job.ID}
if action == "update" {
args["message"] = "changed"
}
result := tool.Execute(ctx, args)
if !result.IsError || !strings.Contains(result.ForLLM, "not accessible") {
t.Fatalf("expected %s to reject other chat command job, got: %+v", action, result)
}
}
}
func TestCronTool_NonAllowlistedRemoteCannotAccessOwnCommandJob(t *testing.T) {
tool := newTestCronTool(t)
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
listResult := tool.Execute(ctx, map[string]any{"action": "list"})
if listResult.IsError || strings.Contains(listResult.ForLLM, job.ID) {
t.Fatalf("expected list to hide non-allowlisted command job, got: %+v", listResult)
}
for _, action := range []string{"get", "update"} {
args := map[string]any{"action": action, "job_id": job.ID}
if action == "update" {
args["message"] = "changed"
}
result := tool.Execute(ctx, args)
if !result.IsError || !strings.Contains(result.ForLLM, "not accessible") {
t.Fatalf("expected %s to reject non-allowlisted command job, got: %+v", action, result)
}
}
}
func TestCronTool_WildcardRemoteCanAccessOwnCommandJob(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"*"}
tool := newTestCronToolWithConfig(t, cfg)
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
other := addTestCronJob(t, tool, "other", "telegram", "chat-2", "uptime")
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
listResult := tool.Execute(ctx, map[string]any{"action": "list"})
if listResult.IsError || !strings.Contains(listResult.ForLLM, job.ID) {
t.Fatalf("expected wildcard list to include own command job, got: %+v", listResult)
}
if strings.Contains(listResult.ForLLM, other.ID) {
t.Fatalf("wildcard list should still hide other chat job, got: %s", listResult.ForLLM)
}
getResult := tool.Execute(ctx, map[string]any{"action": "get", "job_id": job.ID})
if getResult.IsError {
t.Fatalf("expected wildcard get to access own command job, got: %s", getResult.ForLLM)
}
}
func TestCronTool_InternalChannelCanAccessAllCommandJobs(t *testing.T) {
tool := newTestCronTool(t)
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
ctx := WithToolContext(context.Background(), "cli", "direct")
listResult := tool.Execute(ctx, map[string]any{"action": "list"})
if listResult.IsError || !strings.Contains(listResult.ForLLM, job.ID) {
t.Fatalf("expected internal list to include command job, got: %+v", listResult)
}
getResult := tool.Execute(ctx, map[string]any{"action": "get", "job_id": job.ID})
if getResult.IsError {
t.Fatalf("expected internal get to access command job, got: %s", getResult.ForLLM)
}
updateResult := tool.Execute(ctx, map[string]any{
"action": "update",
"job_id": job.ID,
"message": "internal update",
})
if updateResult.IsError {
t.Fatalf("expected internal update to access command job, got: %s", updateResult.ForLLM)
}
}
func TestCronTool_AllowlistedRemoteCanManageOwnCommandJob(t *testing.T) {
for _, action := range []string{"remove", "enable", "disable"} {
t.Run(action, func(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram:chat-1"}
tool := newTestCronToolWithConfig(t, cfg)
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
if action == "enable" {
tool.cronService.EnableJob(job.ID, false)
}
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{"action": action, "job_id": job.ID})
if result.IsError {
t.Fatalf("expected %s to access own command job, got: %s", action, result.ForLLM)
}
saved, ok := tool.cronService.GetJob(job.ID)
switch action {
case "remove":
if ok {
t.Fatalf("remove should delete own command job: %+v", saved)
}
case "enable":
if !ok || !saved.Enabled {
t.Fatalf("enable should enable own command job: %+v", saved)
}
case "disable":
if !ok || saved.Enabled {
t.Fatalf("disable should disable own command job: %+v", saved)
}
}
})
}
}
func TestCronTool_RemoteCannotManageOtherChatJob(t *testing.T) {
for _, action := range []string{"remove", "enable", "disable"} {
t.Run(action, func(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram"}
tool := newTestCronToolWithConfig(t, cfg)
job := addTestCronJob(t, tool, "command", "telegram", "chat-2", "df -h")
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{"action": action, "job_id": job.ID})
if !result.IsError || !strings.Contains(result.ForLLM, "not accessible") {
t.Fatalf("expected %s to reject other chat job, got: %+v", action, result)
}
saved, ok := tool.cronService.GetJob(job.ID)
if !ok {
t.Fatalf("%s should not remove other chat job", action)
}
if !saved.Enabled {
t.Fatalf("%s should not disable other chat job: %+v", action, saved)
}
})
}
}
func TestCronTool_RemoteCannotManageCommandJobUnlessAllowlisted(t *testing.T) {
for _, action := range []string{"remove", "enable", "disable"} {
t.Run(action, func(t *testing.T) {
tool := newTestCronTool(t)
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{"action": action, "job_id": job.ID})
if !result.IsError || !strings.Contains(result.ForLLM, "not accessible") {
t.Fatalf("expected %s to reject non-allowlisted command job, got: %+v", action, result)
}
saved, ok := tool.cronService.GetJob(job.ID)
if !ok {
t.Fatalf("%s should not remove non-allowlisted command job", action)
}
if !saved.Enabled {
t.Fatalf("%s should not disable non-allowlisted command job: %+v", action, saved)
}
})
}
}
func TestCronTool_InternalChannelCanManageAllJobs(t *testing.T) {
for _, action := range []string{"remove", "enable", "disable"} {
t.Run(action, func(t *testing.T) {
tool := newTestCronTool(t)
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
if action == "enable" {
tool.cronService.EnableJob(job.ID, false)
}
ctx := WithToolContext(context.Background(), "cli", "direct")
result := tool.Execute(ctx, map[string]any{"action": action, "job_id": job.ID})
if result.IsError {
t.Fatalf("expected internal %s to access command job, got: %s", action, result.ForLLM)
}
saved, ok := tool.cronService.GetJob(job.ID)
switch action {
case "remove":
if ok {
t.Fatalf("internal remove should delete command job: %+v", saved)
}
case "enable":
if !ok || !saved.Enabled {
t.Fatalf("internal enable should enable command job: %+v", saved)
}
case "disable":
if !ok || saved.Enabled {
t.Fatalf("internal disable should disable command job: %+v", saved)
}
}
})
}
}
func TestCronTool_RemoteCanManageOwnNonCommandJob(t *testing.T) {
for _, action := range []string{"remove", "enable", "disable"} {
t.Run(action, func(t *testing.T) {
tool := newTestCronTool(t)
job := addTestCronJob(t, tool, "reminder", "telegram", "chat-1", "")
if action == "enable" {
tool.cronService.EnableJob(job.ID, false)
}
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{"action": action, "job_id": job.ID})
if result.IsError {
t.Fatalf("expected %s to access own non-command job, got: %s", action, result.ForLLM)
}
})
}
}
func TestCronTool_WildcardRemoteCanManageOwnCommandJob(t *testing.T) {
for _, action := range []string{"remove", "enable", "disable"} {
t.Run(action, func(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"*"}
tool := newTestCronToolWithConfig(t, cfg)
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
if action == "enable" {
tool.cronService.EnableJob(job.ID, false)
}
other := addTestCronJob(t, tool, "other", "telegram", "chat-2", "uptime")
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{"action": action, "job_id": job.ID})
if result.IsError {
t.Fatalf("expected wildcard %s to access own command job, got: %s", action, result.ForLLM)
}
otherResult := tool.Execute(ctx, map[string]any{"action": action, "job_id": other.ID})
if !otherResult.IsError || !strings.Contains(otherResult.ForLLM, "not accessible") {
t.Fatalf("wildcard %s should still reject other chat job, got: %+v", action, otherResult)
}
})
}
}
func TestCronTool_CommandUpdateSafetyGates(t *testing.T) { func TestCronTool_CommandUpdateSafetyGates(t *testing.T) {
t.Run("exec disabled", func(t *testing.T) { t.Run("exec disabled", func(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
+70 -7
View File
@@ -862,7 +862,8 @@ func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, erro
} }
type WriteFileTool struct { type WriteFileTool struct {
fs fileSystem fs fileSystem
altTools []string
} }
func NewWriteFileTool( func NewWriteFileTool(
@@ -874,7 +875,32 @@ func NewWriteFileTool(
if len(allowPaths) > 0 { if len(allowPaths) > 0 {
patterns = allowPaths[0] patterns = allowPaths[0]
} }
return &WriteFileTool{fs: buildFs(workspace, restrict, patterns)} // Default to both alternatives so standalone callers keep the full guidance;
// the agent wiring narrows this to the tools actually registered.
return &WriteFileTool{
fs: buildFs(workspace, restrict, patterns),
altTools: []string{"append_file", "edit_file"},
}
}
// SetAlternativeTools limits which alternatives the copy names, so it never
// directs the model to tools that are not available.
func (t *WriteFileTool) SetAlternativeTools(names []string) {
present := make(map[string]bool, len(names))
for _, name := range names {
present[name] = true
}
ordered := make([]string, 0, 2)
for _, name := range []string{"append_file", "edit_file"} {
if present[name] {
ordered = append(ordered, name)
}
}
t.altTools = ordered
}
func (t *WriteFileTool) altToolsPhrase() string {
return strings.Join(t.altTools, " or ")
} }
func (t *WriteFileTool) Name() string { func (t *WriteFileTool) Name() string {
@@ -882,10 +908,24 @@ func (t *WriteFileTool) Name() string {
} }
func (t *WriteFileTool) Description() string { func (t *WriteFileTool) Description() string {
return "Write content to a file. Content is written byte-for-byte after argument decoding. Standard JSON escaping applies: \\n for newline and \\\\n for a literal backslash-n sequence. If the file already exists, you must set overwrite=true to replace it." desc := "Write content to a file, replacing any existing content. Content is written byte-for-byte after argument decoding. Standard JSON escaping applies: \\n for newline and \\\\n for a literal backslash-n sequence. If the file already exists you must set overwrite=true, which replaces the ENTIRE file."
if phrase := t.altToolsPhrase(); phrase != "" {
desc += fmt.Sprintf(
" To add to or change part of an existing file without losing its current contents, use %s instead.",
phrase,
)
}
return desc
} }
func (t *WriteFileTool) Parameters() map[string]any { func (t *WriteFileTool) Parameters() map[string]any {
overwriteDesc := "Set to true to replace an existing file in full. This discards the file's current contents."
if phrase := t.altToolsPhrase(); phrase != "" {
overwriteDesc = fmt.Sprintf(
"Set to true to replace an existing file in full. This discards the file's current contents — to preserve them, use %s instead of write_file.",
phrase,
)
}
return map[string]any{ return map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
@@ -899,7 +939,7 @@ func (t *WriteFileTool) Parameters() map[string]any {
}, },
"overwrite": map[string]any{ "overwrite": map[string]any{
"type": "boolean", "type": "boolean",
"description": "Must be set to true to overwrite an existing file.", "description": overwriteDesc,
"default": false, "default": false,
}, },
}, },
@@ -922,8 +962,20 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR
if !overwrite { if !overwrite {
if _, err := t.fs.Open(path); err == nil { if _, err := t.fs.Open(path); err == nil {
if phrase := t.altToolsPhrase(); phrase != "" {
return ErrorResult(
fmt.Sprintf(
"file: %s already exists. To add to it or change part of it without losing the current contents, use %s. Only set overwrite=true if you intend to replace the entire file.",
path,
phrase,
),
)
}
return ErrorResult( return ErrorResult(
fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path), fmt.Sprintf(
"file: %s already exists. Set overwrite=true only if you intend to replace the entire file, which discards its current contents.",
path,
),
) )
} }
} }
@@ -1064,8 +1116,8 @@ func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string)
return err return err
} }
// os.Root api on windows only accept forward slashes (/) // os.Root API on Windows only accepts forward slashes (/).
relPath = filepath.ToSlash(relPath) relPath = normalizeRootRelPath(relPath)
return fn(root, relPath) return fn(root, relPath)
} }
@@ -1230,6 +1282,17 @@ func buildFs(workspace string, restrict bool, patterns []*regexp.Regexp) fileSys
return sandbox return sandbox
} }
func normalizeRootRelPath(relPath string) string {
return normalizeRootRelPathForSeparator(relPath, os.PathSeparator)
}
func normalizeRootRelPathForSeparator(relPath string, sep rune) string {
if sep == '\\' {
return strings.ReplaceAll(relPath, `\`, `/`)
}
return relPath
}
// Helper to get a safe relative path for os.Root usage // Helper to get a safe relative path for os.Root usage
func getSafeRelPath(workspace, path string) (string, error) { func getSafeRelPath(workspace, path string) (string, error) {
if workspace == "" { if workspace == "" {
+73
View File
@@ -250,6 +250,9 @@ func TestFilesystemTool_WriteFile_OverwriteDefaultBlocked(t *testing.T) {
assert.True(t, result.IsError, "expected error when overwriting without overwrite=true") assert.True(t, result.IsError, "expected error when overwriting without overwrite=true")
assert.Contains(t, result.ForLLM, "already exists") assert.Contains(t, result.ForLLM, "already exists")
assert.Contains(t, result.ForLLM, "overwrite=true") assert.Contains(t, result.ForLLM, "overwrite=true")
// The guard must steer toward non-destructive tools rather than only coaching overwrite.
assert.Contains(t, result.ForLLM, "append_file")
assert.Contains(t, result.ForLLM, "edit_file")
// Original content must be untouched // Original content must be untouched
data, err := os.ReadFile(testFile) data, err := os.ReadFile(testFile)
@@ -257,6 +260,76 @@ func TestFilesystemTool_WriteFile_OverwriteDefaultBlocked(t *testing.T) {
assert.Equal(t, "original", string(data)) assert.Equal(t, "original", string(data))
} }
// Copy (description, overwrite param, guard) only names available alternatives.
func TestFilesystemTool_WriteFile_AltToolsConditionalCopy(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "existing.txt")
os.WriteFile(testFile, []byte("original"), 0o644)
overwriteParamDesc := func(tool *WriteFileTool) string {
props := tool.Parameters()["properties"].(map[string]any)
return props["overwrite"].(map[string]any)["description"].(string)
}
t.Run("no alternatives available", func(t *testing.T) {
tool := NewWriteFileTool("", false)
tool.SetAlternativeTools(nil)
assert.NotContains(t, tool.Description(), "append_file")
assert.NotContains(t, tool.Description(), "edit_file")
assert.NotContains(t, overwriteParamDesc(tool), "append_file")
assert.NotContains(t, overwriteParamDesc(tool), "edit_file")
result := tool.Execute(context.Background(), map[string]any{
"path": testFile,
"content": "new content",
})
assert.True(t, result.IsError, "expected overwrite guard to still block")
assert.Contains(t, result.ForLLM, "already exists")
assert.Contains(t, result.ForLLM, "overwrite=true")
assert.NotContains(t, result.ForLLM, "append_file")
assert.NotContains(t, result.ForLLM, "edit_file")
})
t.Run("only append_file available", func(t *testing.T) {
tool := NewWriteFileTool("", false)
tool.SetAlternativeTools([]string{"append_file"})
assert.Contains(t, tool.Description(), "append_file")
assert.NotContains(t, tool.Description(), "edit_file")
assert.Contains(t, overwriteParamDesc(tool), "append_file")
assert.NotContains(t, overwriteParamDesc(tool), "edit_file")
result := tool.Execute(context.Background(), map[string]any{
"path": testFile,
"content": "new content",
})
assert.True(t, result.IsError)
assert.Contains(t, result.ForLLM, "append_file")
assert.NotContains(t, result.ForLLM, "edit_file")
})
t.Run("both available uses canonical order", func(t *testing.T) {
tool := NewWriteFileTool("", false)
// Reversed input to confirm the order is normalized.
tool.SetAlternativeTools([]string{"edit_file", "append_file"})
assert.Contains(t, tool.Description(), "append_file or edit_file")
result := tool.Execute(context.Background(), map[string]any{
"path": testFile,
"content": "new content",
})
assert.True(t, result.IsError)
assert.Contains(t, result.ForLLM, "append_file or edit_file")
})
// Blocked writes must leave the original untouched.
data, err := os.ReadFile(testFile)
assert.NoError(t, err)
assert.Equal(t, "original", string(data))
}
// TestFilesystemTool_WriteFile_OverwriteExplicitAllowed verifies that setting // TestFilesystemTool_WriteFile_OverwriteExplicitAllowed verifies that setting
// overwrite=true replaces the existing file. // overwrite=true replaces the existing file.
func TestFilesystemTool_WriteFile_OverwriteExplicitAllowed(t *testing.T) { func TestFilesystemTool_WriteFile_OverwriteExplicitAllowed(t *testing.T) {
@@ -0,0 +1,21 @@
package fstools
import "testing"
func TestNormalizeRootRelPathForWindowsSeparator(t *testing.T) {
got := normalizeRootRelPathForSeparator(`aaa\bbb\file.txt`, '\\')
want := "aaa/bbb/file.txt"
if got != want {
t.Fatalf("normalizeRootRelPathForSeparator() = %q, want %q", got, want)
}
}
func TestNormalizeRootRelPathForUnixSeparatorLeavesBackslashUnchanged(t *testing.T) {
input := `aaa\bbb\file.txt`
got := normalizeRootRelPathForSeparator(input, '/')
if got != input {
t.Fatalf("normalizeRootRelPathForSeparator() = %q, want %q", got, input)
}
}
+41 -270
View File
@@ -55,9 +55,9 @@ var (
`<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`, `<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`,
) )
reSogouTitle = regexp.MustCompile( reSogouTitle = regexp.MustCompile(
`<a\s+class=resultLink\s+href="([^"]+)"[^>]*id="sogou_vr_\d+_\d+"[^>]*>\s*(.*?)\s*</a>`, `<a\s+class="?resultLink"?\s+href="([^"]+)"[^>]*id="sogou_vr_\d+_\d+"[^>]*>\s*(.*?)\s*</a>`,
) )
reSogouSnippet = regexp.MustCompile(`<div class="clamp\d*">\s*(.*?)\s*</div>`) reSogouSnippet = regexp.MustCompile(`<div class="clamp\d*[^"]*">\s*(.*?)\s*</div>`)
reSogouRealURL = regexp.MustCompile(`url=([^&]+)`) reSogouRealURL = regexp.MustCompile(`url=([^&]+)`)
) )
@@ -315,7 +315,7 @@ func (p *BraveSearchProvider) Search(
} }
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
resp.Body.Close() _ = resp.Body.Close()
if err != nil { if err != nil {
lastErr = fmt.Errorf("failed to read response: %w", err) lastErr = fmt.Errorf("failed to read response: %w", err)
@@ -350,6 +350,19 @@ func (p *BraveSearchProvider) Search(
results := searchResp.Web.Results results := searchResp.Web.Results
if len(results) == 0 { if len(results) == 0 {
// Log a warning when the API returned 200 but no results.
// This helps diagnose API format changes or silent errors
// where the response body does not match the expected structure.
bodyPreview := string(body)
if len(bodyPreview) > 300 {
bodyPreview = bodyPreview[:300]
}
logger.WarnCF("web_search", "Brave API returned empty results",
map[string]any{
"query": query,
"status": resp.StatusCode,
"body_preview": bodyPreview,
})
return fmt.Sprintf("No results for: %s", query), nil return fmt.Sprintf("No results for: %s", query), nil
} }
@@ -435,7 +448,7 @@ func (p *TavilySearchProvider) Search(
} }
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
resp.Body.Close() _ = resp.Body.Close()
if err != nil { if err != nil {
lastErr = fmt.Errorf("failed to read response: %w", err) lastErr = fmt.Errorf("failed to read response: %w", err)
@@ -893,7 +906,7 @@ func (p *SogouSearchProvider) Search(
} }
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close() _ = resp.Body.Close()
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read response: %w", err) return "", fmt.Errorf("failed to read response: %w", err)
} }
@@ -1134,7 +1147,7 @@ func (p *PerplexitySearchProvider) Search(
} }
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
resp.Body.Close() _ = resp.Body.Close()
if err != nil { if err != nil {
lastErr = fmt.Errorf("failed to read response: %w", err) lastErr = fmt.Errorf("failed to read response: %w", err)
@@ -1996,16 +2009,9 @@ type WebFetchTool struct {
client *http.Client client *http.Client
format string format string
fetchLimitBytes int64 fetchLimitBytes int64
whitelist *privateHostWhitelist whitelist *utils.PrivateHostWhitelist
} }
type privateHostWhitelist struct {
exact map[string]struct{}
cidrs []*net.IPNet
}
type webFetchAllowedFirstHopHostKey struct{}
func NewWebFetchTool(maxChars int, format string, fetchLimitBytes int64) (*WebFetchTool, error) { func NewWebFetchTool(maxChars int, format string, fetchLimitBytes int64) (*WebFetchTool, error) {
// createHTTPClient cannot fail with an empty proxy string. // createHTTPClient cannot fail with an empty proxy string.
return NewWebFetchToolWithConfig(maxChars, "", format, fetchLimitBytes, nil) return NewWebFetchToolWithConfig(maxChars, "", format, fetchLimitBytes, nil)
@@ -2035,31 +2041,22 @@ func NewWebFetchToolWithConfig(
if maxChars <= 0 { if maxChars <= 0 {
maxChars = defaultMaxChars maxChars = defaultMaxChars
} }
whitelist, err := newPrivateHostWhitelist(privateHostWhitelist) whitelist, err := utils.NewPrivateHostWhitelist(privateHostWhitelist)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse web fetch private host whitelist: %w", err) return nil, fmt.Errorf("failed to parse web fetch private host whitelist: %w", err)
} }
client, err := utils.CreateHTTPClient(proxy, fetchTimeout) client, err := utils.CreateSafeHTTPClient(utils.SafeHTTPClientOptions{
ProxyURL: proxy,
Timeout: fetchTimeout,
PrivateHostWhitelist: privateHostWhitelist,
AllowPrivateHosts: func() bool {
return allowPrivateWebFetchHosts.Load()
},
MaxRedirects: maxRedirects,
})
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create HTTP client for web fetch: %w", err) return nil, fmt.Errorf("failed to create HTTP client for web fetch: %w", err)
} }
if transport, ok := client.Transport.(*http.Transport); ok {
dialer := &net.Dialer{
Timeout: 15 * time.Second,
KeepAlive: 30 * time.Second,
}
transport.DialContext = newSafeDialContext(dialer, whitelist)
}
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= maxRedirects {
return fmt.Errorf("stopped after %d redirects", maxRedirects)
}
if isObviousPrivateHost(req.URL.Hostname(), whitelist) {
return fmt.Errorf("redirect target is private or local network host")
}
allowConfiguredProxyFirstHop(req, client.Transport)
return nil
}
if fetchLimitBytes <= 0 { if fetchLimitBytes <= 0 {
fetchLimitBytes = 10 * 1024 * 1024 // Security Fallback fetchLimitBytes = 10 * 1024 * 1024 // Security Fallback
} }
@@ -2121,7 +2118,9 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
// Lightweight pre-flight: block obvious localhost/literal-IP without DNS resolution. // Lightweight pre-flight: block obvious localhost/literal-IP without DNS resolution.
// The real SSRF guard is newSafeDialContext at connect time. // The real SSRF guard is newSafeDialContext at connect time.
hostname := parsedURL.Hostname() hostname := parsedURL.Hostname()
if isObviousPrivateHost(hostname, t.whitelist) { if utils.IsObviousPrivateHost(hostname, t.whitelist, func() bool {
return allowPrivateWebFetchHosts.Load()
}) {
return ErrorResult("fetching private or local network hosts is not allowed") return ErrorResult("fetching private or local network hosts is not allowed")
} }
@@ -2137,7 +2136,7 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
if reqErr != nil { if reqErr != nil {
return nil, nil, fmt.Errorf("failed to create request: %w", reqErr) return nil, nil, fmt.Errorf("failed to create request: %w", reqErr)
} }
allowConfiguredProxyFirstHop(req, t.client.Transport) utils.AllowConfiguredProxyFirstHop(req, t.client.Transport)
req.Header.Set("User-Agent", ua) req.Header.Set("User-Agent", ua)
resp, doErr := t.client.Do(req) resp, doErr := t.client.Do(req)
if doErr != nil { if doErr != nil {
@@ -2325,247 +2324,19 @@ func (t *WebFetchTool) extractText(htmlContent string) string {
return strings.Join(cleanLines, "\n") return strings.Join(cleanLines, "\n")
} }
// newSafeDialContext re-resolves DNS at connect time to mitigate DNS rebinding (TOCTOU)
// where a hostname resolves to a public IP during pre-flight but a private IP at connect time.
func newSafeDialContext( func newSafeDialContext(
dialer *net.Dialer, dialer *net.Dialer,
whitelist *privateHostWhitelist, whitelist *utils.PrivateHostWhitelist,
) func(context.Context, string, string) (net.Conn, error) { ) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, network, address string) (net.Conn, error) { return utils.NewSafeDialContext(dialer, whitelist, func() bool {
if allowPrivateWebFetchHosts.Load() { return allowPrivateWebFetchHosts.Load()
return dialer.DialContext(ctx, network, address) })
}
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("invalid target address %q: %w", address, err)
}
if host == "" {
return nil, fmt.Errorf("empty target host")
}
if isAllowedFirstHopHost(ctx, host) {
return dialer.DialContext(ctx, network, address)
}
if ip := net.ParseIP(host); ip != nil {
if shouldBlockPrivateIP(ip, whitelist) {
return nil, fmt.Errorf("blocked private or local target: %s", host)
}
return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
}
ipAddrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("failed to resolve %s: %w", host, err)
}
attempted := 0
var lastErr error
for _, ipAddr := range ipAddrs {
if shouldBlockPrivateIP(ipAddr.IP, whitelist) {
continue
}
attempted++
conn, err := dialer.DialContext(
ctx,
network,
net.JoinHostPort(ipAddr.IP.String(), port),
)
if err == nil {
return conn, nil
}
lastErr = err
}
if attempted == 0 {
return nil, fmt.Errorf(
"all resolved addresses for %s are private, restricted, or not whitelisted",
host,
)
}
if lastErr != nil {
return nil, fmt.Errorf(
"failed connecting to public addresses for %s: %w",
host,
lastErr,
)
}
return nil, fmt.Errorf("failed connecting to public addresses for %s", host)
}
} }
func allowConfiguredProxyFirstHop(req *http.Request, rt http.RoundTripper) { func newPrivateHostWhitelist(entries []string) (*utils.PrivateHostWhitelist, error) {
if req == nil { return utils.NewPrivateHostWhitelist(entries)
return
}
transport, ok := rt.(*http.Transport)
if !ok || transport.Proxy == nil {
return
}
proxyURL, err := transport.Proxy(req)
if err != nil || proxyURL == nil {
return
}
host := normalizeAllowedFirstHopHost(proxyURL.Hostname())
if host == "" {
return
}
*req = *req.WithContext(context.WithValue(
req.Context(),
webFetchAllowedFirstHopHostKey{},
host,
))
} }
func isAllowedFirstHopHost(ctx context.Context, host string) bool {
allowed, ok := ctx.Value(webFetchAllowedFirstHopHostKey{}).(string)
if !ok || allowed == "" {
return false
}
return allowed == normalizeAllowedFirstHopHost(host)
}
func normalizeAllowedFirstHopHost(host string) string {
host = strings.ToLower(strings.TrimSpace(host))
return strings.TrimSuffix(host, ".")
}
func newPrivateHostWhitelist(entries []string) (*privateHostWhitelist, error) {
if len(entries) == 0 {
return nil, nil
}
whitelist := &privateHostWhitelist{
exact: make(map[string]struct{}),
cidrs: make([]*net.IPNet, 0, len(entries)),
}
for _, entry := range entries {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if ip := net.ParseIP(entry); ip != nil {
whitelist.exact[normalizeWhitelistIP(ip).String()] = struct{}{}
continue
}
_, network, err := net.ParseCIDR(entry)
if err != nil {
return nil, fmt.Errorf("invalid entry %q: expected IP or CIDR", entry)
}
whitelist.cidrs = append(whitelist.cidrs, network)
}
if len(whitelist.exact) == 0 && len(whitelist.cidrs) == 0 {
return nil, nil
}
return whitelist, nil
}
func (w *privateHostWhitelist) Contains(ip net.IP) bool {
if w == nil || ip == nil {
return false
}
normalized := normalizeWhitelistIP(ip)
if _, ok := w.exact[normalized.String()]; ok {
return true
}
for _, network := range w.cidrs {
if network.Contains(normalized) {
return true
}
}
return false
}
func normalizeWhitelistIP(ip net.IP) net.IP {
if ip == nil {
return nil
}
if ip4 := ip.To4(); ip4 != nil {
return ip4
}
return ip
}
func shouldBlockPrivateIP(ip net.IP, whitelist *privateHostWhitelist) bool {
return isPrivateOrRestrictedIP(ip) && !whitelist.Contains(ip)
}
// isObviousPrivateHost performs a lightweight, no-DNS check for obviously private hosts.
// It catches localhost, literal private IPs, and empty hosts. It does NOT resolve DNS —
// the real SSRF guard is newSafeDialContext which checks IPs at connect time.
func isObviousPrivateHost(host string, whitelist *privateHostWhitelist) bool {
if allowPrivateWebFetchHosts.Load() {
return false
}
h := strings.ToLower(strings.TrimSpace(host))
h = strings.TrimSuffix(h, ".")
if h == "" {
return true
}
if h == "localhost" || strings.HasSuffix(h, ".localhost") {
return true
}
if ip := net.ParseIP(h); ip != nil {
return shouldBlockPrivateIP(ip, whitelist)
}
return false
}
// isPrivateOrRestrictedIP returns true for IPs that should never be reached via web_fetch:
// RFC 1918, loopback, link-local (incl. cloud metadata 169.254.x.x), carrier-grade NAT,
// benchmark (198.18.0.0/15), IPv6 unique-local (fc00::/7), 6to4 (2002::/16), and
// Teredo (2001:0000::/32).
func isPrivateOrRestrictedIP(ip net.IP) bool { func isPrivateOrRestrictedIP(ip net.IP) bool {
if ip == nil { return utils.IsPrivateOrRestrictedIP(ip)
return true
}
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsMulticast() || ip.IsUnspecified() {
return true
}
if ip4 := ip.To4(); ip4 != nil {
// IPv4 private, loopback, link-local, and carrier-grade NAT ranges.
if ip4[0] == 10 ||
ip4[0] == 127 ||
ip4[0] == 0 ||
(ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31) ||
(ip4[0] == 192 && ip4[1] == 168) ||
(ip4[0] == 169 && ip4[1] == 254) ||
(ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127) ||
(ip4[0] == 198 && ip4[1] >= 18 && ip4[1] <= 19) {
return true
}
return false
}
if len(ip) == net.IPv6len {
// IPv6 unique local addresses (fc00::/7)
if (ip[0] & 0xfe) == 0xfc {
return true
}
// 6to4 addresses (2002::/16): check the embedded IPv4 at bytes [2:6].
if ip[0] == 0x20 && ip[1] == 0x02 {
embedded := net.IPv4(ip[2], ip[3], ip[4], ip[5])
return isPrivateOrRestrictedIP(embedded)
}
// Teredo (2001:0000::/32): client IPv4 is at bytes [12:16], XOR-inverted.
if ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x00 && ip[3] == 0x00 {
client := net.IPv4(ip[12]^0xff, ip[13]^0xff, ip[14]^0xff, ip[15]^0xff)
return isPrivateOrRestrictedIP(client)
}
}
return false
} }
+25
View File
@@ -845,6 +845,25 @@ func TestWebFetch_Blocks6to4WithPrivateEmbed(t *testing.T) {
} }
} }
// TestWebFetch_BlocksISATAPWithPrivateEmbed verifies ISATAP with private embedded IPv4 is blocked
func TestWebFetch_BlocksISATAPWithPrivateEmbed(t *testing.T) {
tool, err := NewWebFetchTool(50000, format, testFetchLimit)
if err != nil {
t.Fatalf("Failed to create web fetch tool: %v", err)
}
// 2001:db8:1234::5efe:127.0.0.1 embeds 127.0.0.1
result := tool.Execute(context.Background(), map[string]any{
"url": "http://[2001:db8:1234::5efe:127.0.0.1]:0",
})
if !result.IsError {
t.Error("expected error for ISATAP with private embedded IPv4, got success")
}
if !strings.Contains(result.ForLLM, "private or local network hosts is not allowed") {
t.Fatalf("expected private-host guard rejection, got %q", result.ForLLM)
}
}
// TestWebFetch_Allows6to4WithPublicEmbed verifies 6to4 with public embedded IPv4 is NOT blocked // TestWebFetch_Allows6to4WithPublicEmbed verifies 6to4 with public embedded IPv4 is NOT blocked
func TestWebFetch_Allows6to4WithPublicEmbed(t *testing.T) { func TestWebFetch_Allows6to4WithPublicEmbed(t *testing.T) {
tool, err := NewWebFetchTool(50000, format, testFetchLimit) tool, err := NewWebFetchTool(50000, format, testFetchLimit)
@@ -981,6 +1000,12 @@ func TestIsPrivateOrRestrictedIP_Table(t *testing.T) {
{"2002:7f00:0001::1", true, "6to4 with embedded 127.x (private)"}, {"2002:7f00:0001::1", true, "6to4 with embedded 127.x (private)"},
{"2002:0a00:0001::1", true, "6to4 with embedded 10.0.0.1 (private)"}, {"2002:0a00:0001::1", true, "6to4 with embedded 10.0.0.1 (private)"},
{"2002:0801:0101::1", false, "6to4 with embedded 8.1.1.1 (public)"}, {"2002:0801:0101::1", false, "6to4 with embedded 8.1.1.1 (public)"},
{"2001:db8:1234::5efe:127.0.0.1", true, "ISATAP with embedded 127.0.0.1 (private)"},
{"2001:db8:1234::5efe:10.0.0.1", true, "ISATAP with embedded 10.0.0.1 (private)"},
{"2001:db8:1234::5efe:8.8.8.8", false, "ISATAP with embedded 8.8.8.8 (public)"},
{"2001:db8:1234:0:0200:5efe:127.0.0.1", true, "ISATAP 0200 with embedded 127.0.0.1 (private)"},
{"2001:db8:1234:0:0200:5efe:10.0.0.1", true, "ISATAP 0200 with embedded 10.0.0.1 (private)"},
{"2001:db8:1234:0:0200:5efe:8.8.8.8", false, "ISATAP 0200 with embedded 8.8.8.8 (public)"},
{"2001:0000:4136:e378:8000:63bf:f5ff:fffe", true, "Teredo with client 10.0.0.1 (private)"}, {"2001:0000:4136:e378:8000:63bf:f5ff:fffe", true, "Teredo with client 10.0.0.1 (private)"},
{"2001:0000:4136:e378:8000:63bf:f7f6:fefe", false, "Teredo with client 8.9.1.1 (public)"}, {"2001:0000:4136:e378:8000:63bf:f7f6:fefe", false, "Teredo with client 8.9.1.1 (public)"},
{"2607:f8b0:4004:800::200e", false, "public IPv6 (Google)"}, {"2607:f8b0:4004:800::200e", false, "public IPv6 (Google)"},

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