Compare commits

..

202 Commits

Author SHA1 Message Date
dependabot[bot] cadc26c445 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.2.
- [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.2/packages/plugin-react)

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

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

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

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

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

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

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

- version.go: picoclaw version subcommand

- runtime.go: rundll32 for browser launch

- onboard.go: picoclaw onboard subcommand

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

* refactor: consolidate launcherExecCommand into utils package

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 19:27:05 +00:00
afjcjsbx 8e0964be24 Merge remote-tracking branch 'upstream/main' 2026-05-28 19:57:53 +02:00
Mauro 85751492c6 Merge pull request #2950 from yuxuan-7814/fix/2912-add-funding-yml
docs: add FUNDING.yml for GitHub Sponsors
2026-05-28 19:50:38 +02:00
Mauro 0b7e18cd9e Merge pull request #2949 from yuxuan-7814/fix/2944-termux-ssl-cert
fix: auto-detect Termux SSL certificate path
2026-05-28 19:43:18 +02:00
yuxuan-7814 e9e653fb13 docs: add FUNDING.yml for GitHub Sponsors
Add FUNDING.yml file to enable GitHub Sponsors button on the repo.
This makes it easy for users who benefit from PicoClaw to support
the project financially.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 16:36:41 +08:00
Puneet Dixit 17cf91771c docs: add Android Termux guide
Closes #286

Assisted-by: OpenAI Codex

Signed-off-by: Puneet Dixit <236133619+puneetdixit200@users.noreply.github.com>
2026-05-20 19:57:07 +05:30
afjcjsbx f0dcba8c5a fix(seahorse): preserve active tool-call turn when trimming fresh tail 2026-05-20 09:16:09 +02:00
afjcjsbx fe7ded5c13 fix(agent): preserve active turn during context retry rebuild 2026-05-19 09:18:39 +02:00
afjcjsbx 1502636bf0 fix(seahorse): enforce budget on fresh tail and rebuild paths 2026-05-18 21:11:21 +02:00
176 changed files with 10606 additions and 688 deletions
+1
View File
@@ -1,3 +1,4 @@
# Ensure shell scripts always use LF line endings regardless of OS.
*.sh text eol=lf
docker/entrypoint.sh text eol=lf
.gitignore text eol=lf
+3
View File
@@ -0,0 +1,3 @@
# These are supported funding model platforms
github: [sipeed]
+1
View File
@@ -73,3 +73,4 @@ web/backend/dist/*
docker/data
.omc/
.worktrees/
+1 -1
View File
@@ -27,7 +27,7 @@ endif
VERSION?=$(if $(VERSION_RAW),$(VERSION_RAW),dev)
GIT_COMMIT=$(if $(GIT_COMMIT_RAW),$(GIT_COMMIT_RAW),dev)
BUILD_TIME=$(if $(BUILD_TIME_RAW),$(BUILD_TIME_RAW),dev)
GO_VERSION=$(if $(GO_VERSION_RAW),$(GO_VERSION_RAW),unknown)
GO_VERSION=$(if $(GO_VERSION_RAW),$(firstword $(GO_VERSION_RAW)),unknown)
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w
+16 -1
View File
@@ -64,6 +64,16 @@
</a>
</p>
2026-05-28 🚀 **v0.2.9 Released!** MCP server management in Web UI, configurable Sogou-backed web search, tool feedback animation in channels, `pretty_print` and `disable_escape_html` defaults, and numerous bug fixes across providers and channels.
2026-05-14 🚀 **v0.2.8 Released!** MCP CLI commands (`show`, `add`, `list`, `remove`, `test`, `edit`), empty object instead of null for MCP tool parameters, and build fixes.
2026-05-07 🚀 **v0.2.7 Released!** Configurable Sogou-backed web search, channel tool feedback animation, linter fixes.
2026-04-23 🚀 **v0.2.6 Released!** Hooks with respond action and comprehensive documentation, isolation support, help banner fix.
2026-04-11 🚀 **v0.2.5 Released!** Zoneinfo from TZ/ZONEINFO env, Matrix CommonMark rendering alignment, `read_file` by lines.
2026-03-31 📱 **Android Support!** PicoClaw now runs on Android! Download the APK at [picoclaw.io](https://picoclaw.io/download)
2026-03-25 🚀 **v0.2.4 Released!** Agent architecture overhaul (SubTurn, Hooks, Steering, EventBus), WeChat/WeCom integration, security hardening (.security.yml, sensitive data filtering), new providers (AWS Bedrock, Azure, Xiaomi MiMo), and 35 bug fixes. PicoClaw has reached **26K Stars**!
@@ -321,6 +331,8 @@ Download the APK from [picoclaw.io](https://picoclaw.io/download/) and install d
**Option 2: Termux**
For a full command-line setup checklist, see the [Android Termux Guide](docs/guides/android-termux.md).
<details>
<summary><b>Terminal Launcher (for resource-constrained environments)</b></summary>
@@ -413,12 +425,14 @@ PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use
| [Ollama](https://ollama.com/) | `ollama/` | Not needed | Local models, self-hosted |
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Not needed | Local deployment, OpenAI-compatible |
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Varies | Proxy for 100+ providers |
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Required | Enterprise Azure deployment |
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | API key or Entra ID** | Enterprise Azure deployment |
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Device code login |
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
| [AWS Bedrock](https://console.aws.amazon.com/bedrock)* | `bedrock/` | AWS credentials | Claude, Llama, Mistral on AWS |
> \* AWS Bedrock requires build tag: `go build -tags bedrock`. Set `api_base` to a region name (e.g., `us-east-1`) for automatic endpoint resolution across all AWS partitions (aws, aws-cn, aws-us-gov). When using a full endpoint URL instead, you must also configure `AWS_REGION` via environment variable or AWS config/profile.
>
> \*\* Azure OpenAI uses `api_key` when set. If `api_key` is omitted, the provider falls back to Microsoft Entra ID via `DefaultAzureCredential` (env vars, workload identity, managed identity, Azure CLI, etc.). The Entra ID path requires build tag: `go build -tags azidentity`.
<details>
<summary><b>Local deployment (Ollama, vLLM, etc.)</b></summary>
@@ -497,6 +511,7 @@ PicoClaw can search the web to provide up-to-date information. Configure in `too
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Required | 1500/month (daily allocation) | AI-powered, China-optimized |
| [Tavily](https://tavily.com) | Required | 1000 queries/month | Optimized for AI Agents |
| [Brave Search](https://brave.com/search/api) | Required | 2000 queries/month | Fast and private |
| [Kagi Search](https://help.kagi.com/kagi/api/search.html) | Required | Paid/limited by API setup | Premium search results |
| [Perplexity](https://www.perplexity.ai) | Required | Paid | AI-powered search |
| [SearXNG](https://github.com/searxng/searxng) | Not needed | Self-hosted | Free metasearch engine |
| [GLM Search](https://open.bigmodel.cn/) | Required | Varies | Zhipu web search |
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 KiB

After

Width:  |  Height:  |  Size: 261 KiB

+17 -6
View File
@@ -56,12 +56,23 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
// Print agent startup info (only for interactive mode)
startupInfo := agentLoop.GetStartupInfo()
logger.InfoCF("agent", "Agent initialized",
map[string]any{
"tools_count": startupInfo["tools"].(map[string]any)["count"],
"skills_total": startupInfo["skills"].(map[string]any)["total"],
"skills_available": startupInfo["skills"].(map[string]any)["available"],
})
toolsInfo, ok := startupInfo["tools"].(map[string]any)
if !ok {
toolsInfo = nil
}
skillsInfo, ok := startupInfo["skills"].(map[string]any)
if !ok {
skillsInfo = nil
}
logFields := map[string]any{}
if toolsInfo != nil {
logFields["tools_count"] = toolsInfo["count"]
}
if skillsInfo != nil {
logFields["skills_total"] = skillsInfo["total"]
logFields["skills_available"] = skillsInfo["available"]
}
logger.InfoCF("agent", "Agent initialized", logFields)
if message != "" {
ctx := context.Background()
+6 -4
View File
@@ -79,7 +79,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, target string)
defer cancel()
if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
return fmt.Errorf("\u2717 failed to create skills directory: %v", err)
return fmt.Errorf("\u2717 failed to create skills directory: %w", err)
}
result, err := registry.DownloadAndInstall(ctx, target, "", targetDir)
@@ -345,9 +345,11 @@ func copyDirectory(src, dst string) error {
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
_, copyErr := io.Copy(dstFile, srcFile)
if closeErr := dstFile.Close(); closeErr != nil && copyErr == nil {
return fmt.Errorf("close destination file %s: %w", dstPath, closeErr)
}
return copyErr
})
}
+48
View File
@@ -9,6 +9,8 @@ package main
import (
"fmt"
"os"
"runtime"
"strings"
"time"
"github.com/spf13/cobra"
@@ -33,6 +35,49 @@ import (
var rootNoColor bool
// initTermuxSSL detects Termux environment and sets SSL_CERT_FILE if not already set.
// This fixes X509 certificate errors when running PicoClaw inside Termux or termux-chroot.
// See: https://github.com/sipeed/picoclaw/issues/2944
func initTermuxSSL() {
// Only applicable on Linux/Android
if runtime.GOOS != "linux" && runtime.GOOS != "android" {
return
}
// Skip if already set
if os.Getenv("SSL_CERT_FILE") != "" {
return
}
// Check for Termux prefix in PATH or HOME
home := os.Getenv("HOME")
path := os.Getenv("PATH")
isTermux := strings.Contains(home, "com.termux") ||
strings.Contains(path, "com.termux") ||
strings.Contains(home, "/data/data/com.termux")
if !isTermux {
return
}
// Check common CA bundle locations in Termux
caPaths := []string{
"$PREFIX/etc/tls/cert.pem",
os.Getenv("PREFIX") + "/etc/tls/cert.pem",
"/data/data/com.termux/files/usr/etc/tls/cert.pem",
"/usr/etc/tls/cert.pem",
}
for _, caPath := range caPaths {
expanded := os.ExpandEnv(caPath)
if _, err := os.Stat(expanded); err == nil {
os.Setenv("SSL_CERT_FILE", expanded)
return
}
}
}
func syncCliUIColor(root *cobra.Command) {
no, _ := root.PersistentFlags().GetBool("no-color")
cliui.Init(no || os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb")
@@ -123,6 +168,9 @@ const (
)
func main() {
// Initialize Termux SSL certificate detection before anything else
initTermuxSSL()
cliui.Init(earlyColorDisabled())
if earlyColorDisabled() {
+7
View File
@@ -329,6 +329,13 @@
"base_url": "",
"max_results": 0
},
"kagi": {
"enabled": false,
"api_key": "",
"api_keys": ["YOUR_KAGI_API_KEY"],
"base_url": "https://kagi.com/api/v1/search",
"max_results": 5
},
"provider": "auto",
"sogou": {
"enabled": true,
+1
View File
@@ -3,6 +3,7 @@
Task-oriented guides for setup, configuration, and common PicoClaw workflows.
- [Docker & Quick Start Guide](docker.md): install and run PicoClaw with Docker or the launcher.
- [Android Termux Guide](android-termux.md): run the PicoClaw terminal binary on an ARM64 Android phone.
- [Configuration Guide](configuration.md): environment variables, workspace layout, routing, and sandbox settings.
- [Session Guide](session-guide.md): how session scope affects memory sharing, summaries, and isolation.
- [Routing Guide](routing-guide.md): agent dispatch, session overrides, and light-model routing.
+88
View File
@@ -0,0 +1,88 @@
# Android Termux Guide
> Back to [Guides](README.md)
This guide covers running the PicoClaw terminal binary on an ARM64 Android phone with Termux. Use the APK from [picoclaw.io](https://picoclaw.io/download/) if you want the Android app experience; use Termux when you want a lightweight command-line install on an older or resource-constrained device.
## Requirements
- ARM64 Android device. Run `uname -m` in Termux and use this guide when it prints `aarch64`.
- Termux installed from [Termux GitHub Releases](https://github.com/termux/termux-app/releases) or F-Droid.
- Network access for downloading the release and calling your LLM provider.
- An API key for at least one configured model provider.
## Install PicoClaw
Open Termux and install the packages used by the release archive and chroot wrapper:
```bash
pkg update
pkg install -y wget tar proot
```
Download and unpack the ARM64 Linux release:
```bash
mkdir -p ~/picoclaw
cd ~/picoclaw
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
tar xzf picoclaw_Linux_arm64.tar.gz
chmod +x ./picoclaw
```
Start first-run setup through `termux-chroot`, which gives the Linux binary a more standard filesystem layout than a raw Android userspace:
```bash
termux-chroot ./picoclaw onboard
```
## Configure
Edit the generated config and add at least one model provider API key:
```bash
vim ~/.picoclaw/config.json
```
The default workspace is `~/.picoclaw/workspace`. If you want PicoClaw to read or write Android shared storage, run `termux-setup-storage` first and then point the workspace or any file paths at the mounted storage directory.
See [Configuration Guide](configuration.md) and [Providers & Model Configuration](providers.md) for the available config fields and provider examples.
## Run
Use one-shot agent mode to confirm the installation:
```bash
termux-chroot ./picoclaw agent -m "Hello from Termux"
```
For long-running use, start the gateway:
```bash
termux-chroot ./picoclaw gateway
```
Keep the Termux session open while PicoClaw is running. Android battery optimization can stop background work, so disable battery optimization for Termux if you expect PicoClaw to keep running after the screen locks.
## Update
Your config and workspace live under `~/.picoclaw`, so updating the binary does not remove them:
```bash
cd ~/picoclaw
rm -f picoclaw_Linux_arm64.tar.gz
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
tar xzf picoclaw_Linux_arm64.tar.gz
chmod +x ./picoclaw
termux-chroot ./picoclaw version
```
## Troubleshooting
| Symptom | Check |
|---------|-------|
| `permission denied` | Run `chmod +x ./picoclaw` after unpacking the archive. |
| `not found` after running `./picoclaw` | Confirm `uname -m` prints `aarch64` and that you downloaded `picoclaw_Linux_arm64.tar.gz`. |
| Files or paths behave differently than Linux | Run PicoClaw through `termux-chroot` instead of calling the binary directly. |
| Provider requests fail | Check the API key and network access in `~/.picoclaw/config.json`. |
| PicoClaw stops when the phone sleeps | Disable Android battery optimization for Termux and keep a foreground Termux session active. |
+1
View File
@@ -400,6 +400,7 @@ Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous
|------------|------|---------|-------------|
| `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace |
| `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace |
| `tools.message.media_enabled` | bool | `false` | Allows the `message` tool to attach local media files by path. This is separate from `tools.send_file.enabled`; enable it only when unified text/media/caption delivery is intended. |
### Read File Mode
+1 -1
View File
@@ -97,7 +97,7 @@ Consumer products, routers, and industrial devices that have been tested with Pi
Any ARM64 Android phone (2015+) with 1GB+ RAM. Install [Termux](https://github.com/termux/termux-app), use `proot` to run PicoClaw.
> See [README: Run on old Android Phones](../../README.md#-run-on-old-android-phones) for setup instructions.
> See the [Android Termux Guide](android-termux.md) for setup instructions.
### Desktop / Server / Cloud
+35
View File
@@ -26,6 +26,41 @@ picoclaw cron add --name "Daily summary" --message "Summarize today's logs" --cr
picoclaw cron add --name "Ping" --message "heartbeat" --every 300 --deliver
```
## Agent Tool Actions
The agent-facing `cron` tool supports these actions:
- `add`: create a new job.
- `list`: show accessible job names, ids, and schedules.
- `get`: fetch one accessible persisted job by `job_id`, including its saved payload.
- `update`: partially update one accessible job by `job_id`; omitted fields are preserved.
- `remove`, `enable`, `disable`: existing management actions.
When rescheduling an existing task, use `list -> get -> update`. Do not use
`remove -> add` just to change the schedule, because recreating a job can drop
the original prompt, delivery target, or command payload.
Remote channel access is scoped to the current `channel/chat_id`: remote callers
can only list, get, or update jobs whose saved `payload.channel` and `payload.to`
match the current conversation. Command jobs include a shell command payload, so
they can only be listed, inspected, or updated from internal channels.
Example tool calls:
```json
{"action":"get","job_id":"79095b2f5685a0f2"}
```
```json
{"action":"update","job_id":"79095b2f5685a0f2","cron_expr":"30 10 * * *"}
```
`update` accepts `name`, `message`, `command`, and exactly one schedule field
(`at_seconds`, `every_seconds`, or `cron_expr`).
Omit `command` to preserve it, set `command` to a non-empty string to replace
it, or set `command` to `""` to clear it. Command updates require the same
internal channel and confirmation gates as command creation.
## Execution Modes
Jobs are stored with a message payload and can execute in three stable user-facing modes:
+39
View File
@@ -126,6 +126,44 @@ Baidu Search uses the [Qianfan AI Search API](https://cloud.baidu.com/doc/qianfa
| `api_keys` | string[] | - | Multiple API keys for rotation (takes priority over `api_key`) |
| `max_results` | int | 5 | Maximum number of results |
### Kagi Search
Kagi Search uses the official Kagi OpenAPI client for `POST /search` and returns normal web results from `data.search`.
| Config | Type | Default | Description |
|---------------|----------|---------------------------------------|------------------------------------------------|
| `enabled` | bool | false | Enable Kagi Search |
| `api_key` | string | - | Kagi API key |
| `api_keys` | string[] | - | Multiple API keys for rotation (takes priority over `api_key`) |
| `base_url` | string | `https://kagi.com/api/v1/search` | Kagi Search API endpoint |
| `max_results` | int | 5 | Maximum number of results |
```json
{
"tools": {
"web": {
"provider": "kagi",
"kagi": {
"enabled": true,
"max_results": 5,
"base_url": "https://kagi.com/api/v1/search"
}
}
}
}
```
Store Kagi API keys in `.security.yml`:
```yaml
web:
kagi:
api_keys:
- "YOUR_KAGI_API_KEY"
```
Kagi API usage may be billed or limited separately from a normal Kagi subscription, depending on your account and API setup.
### Tavily
| Config | Type | Default | Description |
@@ -171,6 +209,7 @@ At runtime, the `web_search` tool accepts the following parameters:
| `range` | string | no | Optional time filter: `d` (day), `w` (week), `m` (month), `y` (year) |
If `range` is omitted, PicoClaw performs an unrestricted search.
For Kagi, `d`, `w`, and `m` map to Kagi lens `time_relative`; `y` maps to a lens `time_after` date one year before the current day.
### Example `web_search` Call
+14 -4
View File
@@ -248,13 +248,16 @@ channel_list:
### Web Tools
**Brave, Tavily, Perplexity:**
**Brave, Tavily, Perplexity, Kagi:**
```yaml
web:
brave:
api_keys:
- "key-1"
- "key-2"
kagi:
api_keys:
- "your-kagi-api-key"
```
- Use `api_keys` (plural) array format
@@ -315,16 +318,19 @@ model_list:
- **Rate limit management**: Distribute usage across multiple keys
- **High availability**: Reduce downtime during API provider issues
### Web Tools (Brave/Tavily/Perplexity) - Single key
### Web Tools (Brave/Tavily/Perplexity/Kagi) - Single key
```yaml
web:
brave:
api_keys:
- "BSA-your-key"
kagi:
api_keys:
- "your-kagi-api-key"
```
### Web Tools (Brave/Tavily/Perplexity) - Multiple keys
### Web Tools (Brave/Tavily/Perplexity/Kagi) - Multiple keys
```yaml
web:
@@ -332,6 +338,10 @@ web:
api_keys:
- "BSA-key-1"
- "BSA-key-2"
kagi:
api_keys:
- "kagi-key-1"
- "kagi-key-2"
```
### Web Tool (GLMSearch/BaiduSearch) - Single key only
@@ -558,7 +568,7 @@ go test ./pkg/config -run TestSecurityConfig
- Ensure you're using `api_keys` (plural) in `.security.yml` for models and web tools (except GLMSearch/BaiduSearch)
- Check that the array format is correct in YAML (proper indentation with dashes)
- Remember: Models, Brave, Tavily, Perplexity MUST use `api_keys` (array format)
- Remember: Models, Brave, Tavily, Perplexity, Kagi MUST use `api_keys` (array format)
- GLMSearch and BaiduSearch MUST use `api_key` (single string format)
### Load Balancing/Failover Issues
+30 -16
View File
@@ -1,18 +1,20 @@
module github.com/sipeed/picoclaw
go 1.25.10
go 1.25.11
require (
fyne.io/systray v1.12.1
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1
github.com/SevereCloud/vksdk/v3 v3.3.1
github.com/adhocore/gronx v1.19.7
github.com/anthropics/anthropic-sdk-go v1.26.0
github.com/adhocore/gronx v1.20.0
github.com/anthropics/anthropic-sdk-go v1.46.0
github.com/atc0005/go-teams-notify/v2 v2.14.0
github.com/aws/aws-sdk-go-v2 v1.41.7
github.com/aws/aws-sdk-go-v2 v1.41.11
github.com/aws/aws-sdk-go-v2/config v1.32.17
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.6
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.53.3
github.com/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.4.0
github.com/caarlos0/env/v11 v11.4.1
github.com/charmbracelet/lipgloss v1.1.0
github.com/creack/pty v1.1.24
github.com/eclipse/paho.mqtt.golang v1.5.1
@@ -22,8 +24,9 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/h2non/filetype v1.1.3
github.com/larksuite/oapi-sdk-go/v3 v3.7.5
github.com/line/line-bot-sdk-go/v8 v8.19.0
github.com/kagisearch/kagi-openapi-golang v0.0.0-20260526215348-96575e864d62
github.com/larksuite/oapi-sdk-go/v3 v3.9.4
github.com/line/line-bot-sdk-go/v8 v8.20.0
github.com/mdp/qrterminal/v3 v3.2.1
github.com/minio/selfupdate v0.6.0
github.com/modelcontextprotocol/go-sdk v1.5.0
@@ -31,7 +34,7 @@ require (
github.com/mymmrac/telego v1.9.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0
github.com/pion/rtp v1.10.1
github.com/pion/rtp v1.10.2
github.com/pion/webrtc/v3 v3.3.6
github.com/pmezard/go-difflib v1.0.0
github.com/rs/zerolog v1.35.1
@@ -48,18 +51,20 @@ require (
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.27.0
modernc.org/sqlite v1.50.1
modernc.org/sqlite v1.51.0
rsc.io/qr v0.2.0
)
require (
aead.dev/minisign v0.2.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
github.com/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/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
@@ -67,9 +72,11 @@ require (
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
github.com/aws/smithy-go v1.25.1 // indirect
github.com/aws/smithy-go v1.27.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/beeper/argo-go v1.1.2 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
@@ -82,8 +89,12 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/kylelemons/godebug v1.1.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-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
@@ -91,13 +102,16 @@ require (
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.mau.fi/libsignal v0.2.1 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
@@ -135,9 +149,9 @@ require (
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/arch v0.24.0 // indirect
golang.org/x/crypto v0.51.0
golang.org/x/net v0.54.0
golang.org/x/net v0.55.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.44.0
golang.org/x/sys v0.45.0
)
replace github.com/bwmarrin/discordgo => github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532
+64 -30
View File
@@ -5,40 +5,52 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ=
fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
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.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
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.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
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.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/SevereCloud/vksdk/v3 v3.3.1 h1:O86zsp5LQnHE+O5acvuXM/s6S1LyxzVTkF6+Lup0Jyg=
github.com/SevereCloud/vksdk/v3 v3.3.1/go.mod h1:c6WaA5aocUYsXfkcUbg2qy45V9M1VDcqHHmHIN14NAw=
github.com/adhocore/gronx v1.19.7 h1:7hhFwChgDw9eHC3+TQ+OKKBqJnP44oWkDCnnW9nrsuA=
github.com/adhocore/gronx v1.19.7/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
github.com/adhocore/gronx v1.20.0 h1:PD13Mo0wekkZ7ZZR9yb1TqeqTfybs7/K3ez9DmjQwEs=
github.com/adhocore/gronx v1.20.0/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
github.com/anthropics/anthropic-sdk-go v1.46.0 h1:yl3n+el5ZfNgiCtQ7zQ7s/NXxB11YbrKXdc3uLPNWlU=
github.com/anthropics/anthropic-sdk-go v1.46.0/go.mod h1:bx5vWuHFuGPkELH8Z4KUiNSohFnUwScdpTyr+50myPo=
github.com/atc0005/go-teams-notify/v2 v2.14.0 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZJED9nMLyscCo=
github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q=
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
github.com/aws/aws-sdk-go-v2 v1.41.11 h1:9PRf7jyTMEUM6fuNRAJa2mO/skJfrF50rENJwf2LXqw=
github.com/aws/aws-sdk-go-v2 v1.41.11/go.mod h1:iiUX27gOXRuYaoeUVXhUpPwjJHzISfPAjjcuhUbLSVs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12 h1:oRtsqWgxbpeXrOlxOoQStx2M9WNbIkPq4C4Xn1or6bc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12/go.mod h1:Zg0Oe9qT+9wcezlm1a64wGJp2qZdRElVxo/seJf7jYU=
github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27 h1:8sPbKi1/KRHwl5oR3qN9mUXestCeHuaRutxylnr/eVY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27/go.mod h1:QV9IVIopJ1dpQUno0f9VYDUwOEjj8u0iEJ4JiZVre3Y=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27 h1:9d8AoASQY9UwrOSmiJ7uSM0MGUPFhnenwSvpaFfat2c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27/go.mod h1:x0rldpsnUQaQIs4Rh+Vwm9Z/0vI6BxadGtsgJfZFb8s=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.6 h1:Wbo1WlWyGaAXlr6C7OGXq9avbdJhIV9cQ4M6E34b5x8=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.6/go.mod h1:uY1fJe6m3I3w/m8UAkQ89Cm/ZAt/um6LW+AOZU33LDI=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.53.3 h1:HTzzFDJiFSNkZX1Al72+insR4dre/vUeT3YZ4b9h0MA=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.53.3/go.mod h1:dFhfMfXoFrnX6XK/gXDh+4azdybtKll2QnP239wm2O8=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
@@ -51,20 +63,24 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/smithy-go v1.27.0 h1:ZoFioDKJxkSIW2otF9T0aPtNlUwhdVCcuZh/rzH9Hus=
github.com/aws/smithy-go v1.27.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw=
github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc=
github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw=
github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
@@ -164,6 +180,13 @@ github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
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/go.mod h1:vONkS+clG730HSKOw3nZVa22TjB21r6csKYzYt0a9zI=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
@@ -179,12 +202,16 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/larksuite/oapi-sdk-go/v3 v3.7.5 h1:dimv+ZAGia01f4xCDGvCiBHKWMf4K1AB7fGsM+lv5Jw=
github.com/larksuite/oapi-sdk-go/v3 v3.7.5/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/line/line-bot-sdk-go/v8 v8.19.0 h1:5FD/1SprRZ8Y0FiUI6syYiBewOs0ak2tuUBMYN0wzE4=
github.com/line/line-bot-sdk-go/v8 v8.19.0/go.mod h1:AeSRUuu7WGgveGDJb6DyKyFUOst2UB2aF6LO2cQeuXs=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/larksuite/oapi-sdk-go/v3 v3.9.4 h1:oMgcY7NBjJv1QXJqFAfcoN/TbScCkCuRZfbb1mCwZmI=
github.com/larksuite/oapi-sdk-go/v3 v3.9.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/line/line-bot-sdk-go/v8 v8.20.0 h1:Jv22DV3JuQ5qZvniqUbg504bJrVzffXs2CMpyoiuIZU=
github.com/line/line-bot-sdk-go/v8 v8.20.0/go.mod h1:QMXJwPka2ysSeVQKWXkBp8DzBFs+CFAXFNo75KJtWho=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/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/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -221,10 +248,12 @@ github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VR
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo=
github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE=
github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -253,6 +282,8 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI=
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -293,6 +324,8 @@ 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/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/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/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
@@ -354,8 +387,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
@@ -384,12 +417,13 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -471,8 +505,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+7 -5
View File
@@ -350,11 +350,13 @@ func (al *AgentLoop) buildCommandsRuntime(
}
history := agent.Sessions.GetHistory(opts.SessionKey)
return &commands.ContextStats{
UsedTokens: usage.UsedTokens,
TotalTokens: usage.TotalTokens,
CompressAtTokens: usage.CompressAtTokens,
UsedPercent: usage.UsedPercent,
MessageCount: len(history),
UsedTokens: usage.UsedTokens,
TotalTokens: usage.TotalTokens,
HistoryTokens: usage.HistoryTokens,
CompressAtTokens: usage.CompressAtTokens,
SummarizeAtTokens: usage.SummarizeAtTokens,
UsedPercent: usage.UsedPercent,
MessageCount: len(history),
}
}
}
+36 -4
View File
@@ -161,26 +161,58 @@ func registerSharedTools(
// Message tool
if cfg.Tools.IsToolEnabled("message") {
messageTool := tools.NewMessageTool()
if cfg.Tools.Message.MediaEnabled {
messageTool.ConfigureLocalMedia(
agent.Workspace,
cfg.Agents.Defaults.RestrictToWorkspace,
cfg.Agents.Defaults.GetMaxMediaSize(),
allowReadPaths,
)
}
messageTool.SetSendCallback(func(
ctx context.Context,
channel, chatID, content, replyToMessageID string,
mediaParts []bus.MediaPart,
) error {
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
outboundCtx := bus.NewOutboundContext(channel, chatID, replyToMessageID)
outboundAgentID, outboundSessionKey, outboundScope := outboundTurnMetadata(
tools.ToolAgentID(ctx),
tools.ToolSessionKey(ctx),
tools.ToolSessionScope(ctx),
)
return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
if len(mediaParts) > 0 {
outboundMedia := bus.OutboundMediaMessage{
Channel: channel,
ChatID: chatID,
Context: outboundCtx,
AgentID: outboundAgentID,
SessionKey: outboundSessionKey,
Scope: outboundScope,
Parts: mediaParts,
}
if al.channelManager != nil && channel != "" {
return al.channelManager.SendMedia(ctx, outboundMedia)
}
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
return msgBus.PublishOutboundMedia(pubCtx, outboundMedia)
}
outboundMessage := bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Context: outboundCtx,
AgentID: outboundAgentID,
SessionKey: outboundSessionKey,
Scope: outboundScope,
Content: content,
ReplyToMessageID: replyToMessageID,
})
}
if al.channelManager != nil && channel != "" {
return al.channelManager.SendMessage(ctx, outboundMessage)
}
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
return msgBus.PublishOutbound(pubCtx, outboundMessage)
})
agent.Tools.Register(messageTool)
}
+46 -1
View File
@@ -377,7 +377,11 @@ func TestPublishResponseIfNeeded_DismissesToolFeedbackWhenMessageToolAlreadySent
t.Fatal("expected default agent")
}
mt := tools.NewMessageTool()
mt.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
mt.SetSendCallback(func(
ctx context.Context,
channel, chatID, content, replyToMessageID string,
mediaParts []bus.MediaPart,
) error {
return nil
})
defaultAgent.Tools.Register(mt)
@@ -605,6 +609,43 @@ func TestProcessMessage_PassesExplicitThinkingOffToProviderWithoutThinkingCapabi
}
}
func TestProcessMessage_PassesDeepSeekThinkingLevelToThinkingCapableProvider(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: t.TempDir(),
ModelName: "deepseek-v4-flash",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
ModelList: []*config.ModelConfig{{
ModelName: "deepseek-v4-flash",
Provider: "deepseek",
Model: "deepseek-v4-flash",
ThinkingLevel: "xhigh",
}},
}
provider := &thinkingRecordingProvider{}
al := NewAgentLoop(cfg, bus.NewMessageBus(), provider)
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
Channel: "pico",
ChatID: "chat-1",
Content: "hello",
}))
if err != nil {
t.Fatalf("processMessage() error = %v", err)
}
if response != "Mock response" {
t.Fatalf("processMessage() response = %q, want %q", response, "Mock response")
}
if got := provider.lastOptions["thinking_level"]; got != "xhigh" {
t.Fatalf("thinking_level option = %#v, want %q", got, "xhigh")
}
}
func TestProcessMessage_SuppressesReasoningWhenThinkingOff(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
@@ -1062,6 +1103,8 @@ func TestProcessMessage_BtwCommandRunsWithoutPersistingHistory(t *testing.T) {
defaultAgent.Sessions.SetHistory(sessionKey, initialHistory)
defaultAgent.Sessions.SetSummary(sessionKey, "The team decided to keep state request-scoped.")
initialHistory = defaultAgent.Sessions.GetHistory(sessionKey)
response, err := al.processMessage(context.Background(), msg)
if err != nil {
t.Fatalf("processMessage() error = %v", err)
@@ -1180,6 +1223,8 @@ func TestProcessMessage_BtwCommandUsesIsolatedProvider(t *testing.T) {
}
defaultAgent.Sessions.SetHistory(mainSessionKey, initialHistory)
initialHistory = defaultAgent.Sessions.GetHistory(mainSessionKey)
// Process a /btw command
response, err := al.processMessage(context.Background(), bus.InboundMessage{
Channel: "telegram",
+7 -1
View File
@@ -102,7 +102,13 @@ func NewContextBuilder(workspace string) *ContextBuilder {
// Use the skills/ directory under the current working directory
builtinSkillsDir := strings.TrimSpace(os.Getenv(config.EnvBuiltinSkills))
if builtinSkillsDir == "" {
wd, _ := os.Getwd()
wd, err := os.Getwd()
if err != nil {
// os.Getwd failure is extremely rare; fall back to empty
// string so that filepath.Join produces a relative "skills"
// path, preserving the original lookup behavior.
wd = ""
}
builtinSkillsDir = filepath.Join(wd, "skills")
}
globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills")
+52
View File
@@ -115,3 +115,55 @@ func isOverContextBudget(
return total > contextWindow
}
// trimHistoryToFitContextWindow rebuilds the prompt from progressively newer
// history slices until it fits within the context window. Oldest complete turns
// are dropped first so tool-call sequences remain intact.
func trimHistoryToFitContextWindow(
history []providers.Message,
build func([]providers.Message) []providers.Message,
contextWindow int,
toolDefs []providers.ToolDefinition,
maxTokens int,
) ([]providers.Message, []providers.Message, bool) {
messages := build(history)
if !isOverContextBudget(contextWindow, messages, toolDefs, maxTokens) {
return history, messages, true
}
trimmedHistory := append([]providers.Message(nil), history...)
for len(trimmedHistory) > 0 {
dropUntil := nextHistoryTrimStart(trimmedHistory)
if dropUntil <= 0 || dropUntil >= len(trimmedHistory) {
trimmedHistory = nil
} else {
trimmedHistory = append([]providers.Message(nil), trimmedHistory[dropUntil:]...)
}
messages = build(trimmedHistory)
if !isOverContextBudget(contextWindow, messages, toolDefs, maxTokens) {
return trimmedHistory, messages, true
}
}
return nil, messages, false
}
func nextHistoryTrimStart(history []providers.Message) int {
if len(history) == 0 {
return 0
}
turns := parseTurnBoundaries(history)
if len(turns) >= 2 {
return turns[1]
}
if len(turns) == 1 {
if turns[0] > 0 {
return turns[0]
}
return len(history)
}
return len(history)
}
+61
View File
@@ -844,3 +844,64 @@ func TestIsOverContextBudget_RealisticSession(t *testing.T) {
t.Error("realistic session should exceed 500 context window")
}
}
func TestTrimHistoryToFitContextWindow_DropsOldestTurns(t *testing.T) {
history := []providers.Message{
msgUser(strings.Repeat("u1 ", 120)),
msgAssistant(strings.Repeat("a1 ", 120)),
msgUser(strings.Repeat("u2 ", 120)),
msgAssistant(strings.Repeat("a2 ", 120)),
msgUser(strings.Repeat("u3 ", 120)),
msgAssistant(strings.Repeat("a3 ", 120)),
}
build := func(history []providers.Message) []providers.Message {
return append([]providers.Message(nil), history...)
}
trimmedHistory, messages, fit := trimHistoryToFitContextWindow(
history,
build,
700,
nil,
0,
)
if !fit {
t.Fatal("expected trimmed history to fit context window")
}
if len(trimmedHistory) != 4 {
t.Fatalf("trimmed history len = %d, want 4", len(trimmedHistory))
}
if trimmedHistory[0].Content != history[2].Content {
t.Fatalf("first kept message = %q, want second turn start", trimmedHistory[0].Content)
}
if isOverContextBudget(700, messages, nil, 0) {
t.Fatal("trimmed messages should be within budget")
}
}
func TestTrimHistoryToFitContextWindow_ClearsSingleOversizedTurn(t *testing.T) {
history := []providers.Message{
msgUser(strings.Repeat("oversized ", 200)),
msgAssistant(strings.Repeat("oversized ", 200)),
}
trimmedHistory, messages, fit := trimHistoryToFitContextWindow(
history,
func(history []providers.Message) []providers.Message {
return append([]providers.Message(nil), history...)
},
200,
nil,
0,
)
if !fit {
t.Fatal("expected empty history rebuild to fit context window")
}
if len(trimmedHistory) != 0 {
t.Fatalf("trimmed history len = %d, want 0", len(trimmedHistory))
}
if len(messages) != 0 {
t.Fatalf("messages len = %d, want 0", len(messages))
}
}
+9
View File
@@ -6,6 +6,7 @@ import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
@@ -200,6 +201,7 @@ func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message {
ModelName: msg.ModelName,
ReasoningContent: msg.ReasoningContent,
TokenCount: tokenizer.EstimateMessageTokens(msg),
CreatedAt: normalizeSeahorseMessageCreatedAt(msg.CreatedAt),
}
// Convert ToolCalls → MessageParts
@@ -235,6 +237,13 @@ func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message {
return result
}
func normalizeSeahorseMessageCreatedAt(createdAt *time.Time) time.Time {
if createdAt == nil || createdAt.IsZero() {
return time.Time{}
}
return createdAt.UTC().Truncate(time.Second)
}
// seahorseToProviderMessages converts a seahorse.AssembleResult to []providers.Message.
func seahorseToProviderMessages(result *seahorse.AssembleResult) []protocoltypes.Message {
messages := make([]protocoltypes.Message, 0, len(result.Messages))
+73
View File
@@ -171,11 +171,13 @@ func TestProviderToSeahorseMessageWithMedia(t *testing.T) {
}
func TestProviderToSeahorseMessageWithReasoning(t *testing.T) {
createdAt := time.Date(2026, 5, 6, 7, 8, 9, 123000000, time.UTC)
msg := protocoltypes.Message{
Role: "assistant",
Content: "response text",
ModelName: "gpt-5.4-mini",
ReasoningContent: "I thought about this carefully",
CreatedAt: &createdAt,
}
result := providerToSeahorseMessage(msg)
@@ -185,6 +187,9 @@ func TestProviderToSeahorseMessageWithReasoning(t *testing.T) {
if result.ModelName != "gpt-5.4-mini" {
t.Errorf("ModelName = %q, want %q", result.ModelName, "gpt-5.4-mini")
}
if !result.CreatedAt.Equal(time.Date(2026, 5, 6, 7, 8, 9, 0, time.UTC)) {
t.Errorf("CreatedAt = %v, want 2026-05-06 07:08:09 UTC", result.CreatedAt)
}
}
func TestSeahorseToProviderMessagesWithReasoning(t *testing.T) {
@@ -288,6 +293,74 @@ func TestSeahorseToProviderMessagesWithToolCalls(t *testing.T) {
}
}
func TestSeahorseAssemblePreservesActiveToolTurnAcrossSanitization(t *testing.T) {
engine, err := seahorse.NewEngine(seahorse.Config{
DBPath: t.TempDir() + "/seahorse.db",
}, nil)
if err != nil {
t.Fatalf("NewEngine: %v", err)
}
ctx := context.Background()
sessionKey := "test:active-tool-turn"
_, err = engine.Ingest(ctx, sessionKey, []seahorse.Message{
{
Role: "assistant",
Content: "older context",
TokenCount: 20,
},
{
Role: "user",
Content: "inspect the file",
TokenCount: 5,
},
{
Role: "assistant",
TokenCount: 5,
Parts: []seahorse.MessagePart{{
Type: "tool_use",
Name: "read_file",
Arguments: `{"path":"/tmp/test.txt"}`,
ToolCallID: "tc_1",
}},
},
{
Role: "tool",
TokenCount: 200,
Parts: []seahorse.MessagePart{{
Type: "tool_result",
ToolCallID: "tc_1",
Text: "very large tool output",
}},
},
{
Role: "assistant",
Content: "done",
TokenCount: 5,
},
})
if err != nil {
t.Fatalf("Ingest: %v", err)
}
result, err := engine.Assemble(ctx, sessionKey, seahorse.AssembleInput{Budget: 210})
if err != nil {
t.Fatalf("Assemble: %v", err)
}
sanitized := sanitizeHistoryForProvider(seahorseToProviderMessages(result))
if len(sanitized) != 4 {
t.Fatalf("sanitized history len = %d, want 4 protected-turn messages", len(sanitized))
}
assertRoles(t, sanitized, "user", "assistant", "tool", "assistant")
if len(sanitized[1].ToolCalls) != 1 || sanitized[1].ToolCalls[0].ID != "tc_1" {
t.Fatalf("assistant tool calls = %+v, want preserved tool call tc_1", sanitized[1].ToolCalls)
}
if sanitized[2].ToolCallID != "tc_1" {
t.Fatalf("tool result id = %q, want tc_1", sanitized[2].ToolCallID)
}
}
func TestSeahorseToProviderMessagesToolResult(t *testing.T) {
msg := seahorse.Message{
Role: "tool",
+17 -4
View File
@@ -61,6 +61,17 @@ func computeContextUsage(agent *AgentInstance, sessionKey string) *bus.ContextUs
// proactive trigger (msgTokens + toolTokens + maxTokens > contextWindow).
compressAt := effectiveWindow
// summarizeAt = soft summarization trigger: matches maybeSummarize's
// threshold (contextWindow * SummarizeTokenPercent / 100).
//
// The engine compares this against history-message tokens ONLY (not
// UsedTokens). HistoryTokens is exposed alongside UsedTokens so the
// UI can show both values and avoid user confusion.
summarizeAt := contextWindow * agent.SummarizeTokenPercent / 100
if summarizeAt <= 0 {
summarizeAt = compressAt
}
usedPercent := 0
if compressAt > 0 {
usedPercent = usedTokens * 100 / compressAt
@@ -70,9 +81,11 @@ func computeContextUsage(agent *AgentInstance, sessionKey string) *bus.ContextUs
}
return &bus.ContextUsage{
UsedTokens: usedTokens,
TotalTokens: contextWindow,
CompressAtTokens: compressAt,
UsedPercent: usedPercent,
UsedTokens: usedTokens,
TotalTokens: contextWindow,
HistoryTokens: historyTokens,
CompressAtTokens: compressAt,
SummarizeAtTokens: summarizeAt,
UsedPercent: usedPercent,
}
}
+4 -2
View File
@@ -2,7 +2,6 @@ package agent
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
@@ -414,7 +413,10 @@ func compilePatterns(patterns []string) []*regexp.Regexp {
for _, p := range patterns {
re, err := regexp.Compile(p)
if err != nil {
fmt.Printf("Warning: invalid path pattern %q: %v\n", p, err)
logger.WarnCF("agent", "invalid path pattern in compilePatterns", map[string]any{
"pattern": p,
"error": err.Error(),
})
continue
}
compiled = append(compiled, re)
+10 -1
View File
@@ -2,11 +2,13 @@ package agent
import (
"context"
"fmt"
"sync"
"sync/atomic"
"github.com/sipeed/picoclaw/pkg/bus"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/logger"
)
const defaultEventSubscriberBuffer = 16
@@ -88,7 +90,14 @@ func (al *AgentLoop) UnsubscribeEvents(id uint64) {
if !ok {
return
}
sub := value.(legacyEventSubscription)
sub, ok := value.(legacyEventSubscription)
if !ok {
logger.WarnCF("agent", "UnsubscribeEvents: unexpected type in subscription map", map[string]any{
"id": id,
"type": fmt.Sprintf("%T", value),
})
return
}
sub.cancel()
if sub.sub != nil {
_ = sub.sub.Close()
+103 -52
View File
@@ -280,22 +280,8 @@ func (p *Pipeline) CallLLM(
}
errMsg := strings.ToLower(err.Error())
isTimeoutError := errors.Is(err, context.DeadlineExceeded) ||
strings.Contains(errMsg, "deadline exceeded") ||
strings.Contains(errMsg, "client.timeout") ||
strings.Contains(errMsg, "timed out") ||
strings.Contains(errMsg, "timeout exceeded")
isNetworkError := !isTimeoutError && (strings.Contains(errMsg, "connection reset") ||
strings.Contains(errMsg, "connection refused") ||
strings.Contains(errMsg, "broken pipe") ||
strings.Contains(errMsg, "no such host") ||
strings.Contains(errMsg, "network is unreachable") ||
strings.Contains(errMsg, "read tcp") ||
strings.Contains(errMsg, "write tcp") ||
strings.Contains(errMsg, "eof"))
isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") ||
retryReason, isTransientError := transientLLMRetryReason(err)
isContextError := !isTransientError && (strings.Contains(errMsg, "context_length_exceeded") ||
strings.Contains(errMsg, "context window") ||
strings.Contains(errMsg, "context_window") ||
strings.Contains(errMsg, "maximum context length") ||
@@ -306,7 +292,7 @@ func (p *Pipeline) CallLLM(
strings.Contains(errMsg, "prompt is too long") ||
strings.Contains(errMsg, "request too large"))
if isTimeoutError && retry < maxRetries {
if isTransientError && retry < maxRetries {
backoff := time.Duration(retry+1) * time.Duration(backoffSecs) * time.Second
al.emitEvent(
runtimeevents.KindAgentLLMRetry,
@@ -314,42 +300,14 @@ func (p *Pipeline) CallLLM(
LLMRetryPayload{
Attempt: retry + 1,
MaxRetries: maxRetries,
Reason: "timeout",
Reason: retryReason,
Error: err.Error(),
Backoff: backoff,
},
)
logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{
"error": err.Error(),
"retry": retry,
"backoff": backoff.String(),
})
if sleepErr := sleepWithContext(turnCtx, backoff); sleepErr != nil {
if ts.hardAbortRequested() {
_ = ts.requestHardAbort()
return ControlBreak, nil
}
err = sleepErr
break
}
continue
}
if isNetworkError && retry < maxRetries {
backoff := time.Duration(retry+1) * time.Duration(backoffSecs) * time.Second
al.emitEvent(
runtimeevents.KindAgentLLMRetry,
ts.eventMeta("runTurn", "turn.llm.retry"),
LLMRetryPayload{
Attempt: retry + 1,
MaxRetries: maxRetries,
Reason: "network",
Error: err.Error(),
Backoff: backoff,
},
)
logger.WarnCF("agent", "Network error, retrying after backoff", map[string]any{
logger.WarnCF("agent", "Transient LLM error, retrying after backoff", map[string]any{
"error": err.Error(),
"reason": retryReason,
"retry": retry,
"backoff": backoff.String(),
})
@@ -415,14 +373,65 @@ func (p *Pipeline) CallLLM(
contextualSkills = ts.agent.ContextBuilder.ResolveActiveSkillsForContext(ts.activeSkills)
}
ts.recordSkillContextSnapshot(skillContextTriggerContextRetryRebuild, contextualSkills)
rebuildPromptReq := promptBuildRequestForTurn(ts, exec.history, exec.summary, "", nil, p.Cfg)
rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
exec.messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
exec.callMessages = exec.messages
stableHistory, protectedTurnTail := splitHistoryForActiveTurn(
exec.history,
ts.persistedMessagesSnapshot(),
)
buildMessages := func(trimmedHistory []providers.Message) []providers.Message {
fullHistory := append(append([]providers.Message(nil), trimmedHistory...), protectedTurnTail...)
rebuildPromptReq := promptBuildRequestForTurn(ts, fullHistory, exec.summary, "", nil, p.Cfg)
rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
return ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
}
originalHistoryCount := len(exec.history)
var fit bool
var trimmedStableHistory []providers.Message
trimmedStableHistory, exec.callMessages, fit = trimHistoryToFitContextWindow(
stableHistory,
func(trimmedHistory []providers.Message) []providers.Message {
rebuilt := buildMessages(trimmedHistory)
if exec.gracefulTerminal {
return append(append([]providers.Message(nil), rebuilt...), ts.interruptHintMessage())
}
return rebuilt
},
ts.agent.ContextWindow,
exec.providerToolDefs,
ts.agent.MaxTokens,
)
exec.history = append(trimmedStableHistory, protectedTurnTail...)
exec.messages = buildMessages(trimmedStableHistory)
if exec.gracefulTerminal {
msgs := append([]providers.Message(nil), exec.messages...)
exec.callMessages = append(msgs, ts.interruptHintMessage())
}
if dropped := originalHistoryCount - len(exec.history); dropped > 0 {
logger.WarnCF("agent", "Trimmed rebuilt history after context retry compaction", map[string]any{
"session_key": ts.sessionKey,
"retry": retry,
"dropped_msgs": dropped,
"remaining_msgs": len(exec.history),
"context_window": ts.agent.ContextWindow,
"max_tokens": ts.agent.MaxTokens,
"still_overlimit": !fit,
})
} else if !fit {
logger.WarnCF("agent", "Context still exceeds budget after retry compaction rebuild", map[string]any{
"session_key": ts.sessionKey,
"retry": retry,
"history_msgs": len(exec.history),
"protected_turn_msgs": len(protectedTurnTail),
"context_window": ts.agent.ContextWindow,
"max_tokens": ts.agent.MaxTokens,
})
}
if !fit {
err = fmt.Errorf(
"context window still exceeded after retry compaction; refusing to drop active turn messages: %w",
err,
)
break
}
continue
}
break
@@ -684,3 +693,45 @@ func providerForFallbackCandidate(
}
return activeProvider, nil
}
func transientLLMRetryReason(err error) (string, bool) {
if err == nil {
return "", false
}
if failErr := providers.ClassifyError(err, "", ""); failErr != nil {
switch failErr.Reason {
case providers.FailoverTimeout:
if failErr.Status >= 500 {
return "server_error", true
}
return "timeout", true
case providers.FailoverNetwork:
return "network", true
case providers.FailoverRateLimit, providers.FailoverOverloaded:
return "rate_limit", true
}
}
errMsg := strings.ToLower(err.Error())
if errors.Is(err, context.DeadlineExceeded) ||
strings.Contains(errMsg, "deadline exceeded") ||
strings.Contains(errMsg, "client.timeout") ||
strings.Contains(errMsg, "timed out") ||
strings.Contains(errMsg, "timeout exceeded") {
return "timeout", true
}
if strings.Contains(errMsg, "connection reset") ||
strings.Contains(errMsg, "connection refused") ||
strings.Contains(errMsg, "broken pipe") ||
strings.Contains(errMsg, "no such host") ||
strings.Contains(errMsg, "network is unreachable") ||
strings.Contains(errMsg, "read tcp") ||
strings.Contains(errMsg, "write tcp") ||
strings.Contains(errMsg, "eof") {
return "network", true
}
return "", false
}
+39 -4
View File
@@ -66,10 +66,45 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
history = resp.History
summary = resp.Summary
}
rebuildPromptReq := promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media, cfg)
rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
originalHistoryCount := len(history)
var fit bool
history, messages, fit = trimHistoryToFitContextWindow(
history,
func(trimmedHistory []providers.Message) []providers.Message {
rebuildPromptReq := promptBuildRequestForTurn(
ts,
trimmedHistory,
summary,
ts.userMessage,
ts.media,
cfg,
)
rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
rebuilt := ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
return resolveMediaRefs(rebuilt, p.MediaStore, maxMediaSize)
},
ts.agent.ContextWindow,
toolDefs,
ts.agent.MaxTokens,
)
if dropped := originalHistoryCount - len(history); dropped > 0 {
logger.WarnCF("agent", "Trimmed rebuilt history after proactive compaction", map[string]any{
"session_key": ts.sessionKey,
"dropped_msgs": dropped,
"remaining_msgs": len(history),
"context_window": ts.agent.ContextWindow,
"max_tokens": ts.agent.MaxTokens,
"still_overlimit": !fit,
})
} else if !fit {
logger.WarnCF("agent", "Context still exceeds budget "+
"after proactive compaction rebuild", map[string]any{
"session_key": ts.sessionKey,
"history_msgs": len(history),
"context_window": ts.agent.ContextWindow,
"max_tokens": ts.agent.MaxTokens,
})
}
}
}
+2
View File
@@ -1618,6 +1618,8 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) {
}
defaultAgent.Sessions.SetHistory(sessionKey, originalHistory)
originalHistory = defaultAgent.Sessions.GetHistory(sessionKey)
runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest(
t,
al,
+85
View File
@@ -193,6 +193,38 @@ func (p *errorProvider) GetDefaultModel() string {
return "error-model"
}
type failOnceLLMProvider struct {
err error
response string
callCount int
mu sync.Mutex
}
func (p *failOnceLLMProvider) Chat(
ctx context.Context,
messages []providers.Message,
tools []providers.ToolDefinition,
model string,
opts map[string]any,
) (*providers.LLMResponse, error) {
p.mu.Lock()
p.callCount++
callCount := p.callCount
p.mu.Unlock()
if callCount == 1 {
return nil, p.err
}
return &providers.LLMResponse{
Content: p.response,
FinishReason: "stop",
}, nil
}
func (p *failOnceLLMProvider) GetDefaultModel() string {
return "fail-once-model"
}
// =============================================================================
// Test Helper Functions
// =============================================================================
@@ -586,6 +618,59 @@ func TestPipeline_CallLLM_TimeoutRetry(t *testing.T) {
}
}
func TestPipeline_CallLLM_HTTP5xxRetry(t *testing.T) {
tmpDir := t.TempDir()
provider := &failOnceLLMProvider{
err: errors.New("API request failed:\n Status: 500\n Body: internal server error"),
response: "Recovered from server error",
}
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
MaxLLMRetries: 1,
LLMRetryBackoffSecs: 1,
},
},
}
msgBus := bus.NewMessageBus()
al := NewAgentLoop(cfg, msgBus, provider)
defer al.Close()
agent := al.registry.GetDefaultAgent()
if agent == nil {
t.Fatal("expected default agent")
}
pipeline := NewPipeline(al)
ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{
turnID: "turn-1",
context: newTurnContext(nil, nil, nil),
})
exec, err := pipeline.SetupTurn(context.Background(), ts)
if err != nil {
t.Fatalf("SetupTurn failed: %v", err)
}
ctrl, err := pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1)
if err != nil {
t.Fatalf("expected HTTP 500 retry to recover, got error: %v", err)
}
if ctrl != ControlBreak {
t.Fatalf("expected ControlBreak, got %v", ctrl)
}
if exec.finalContent != "Recovered from server error" {
t.Fatalf("finalContent = %q, want recovered response", exec.finalContent)
}
if provider.callCount != 2 {
t.Fatalf("callCount = %d, want 2", provider.callCount)
}
}
func TestPipeline_CallLLM_ContextLengthError(t *testing.T) {
errorPrv := &errorProvider{errType: "context_length"}
al, agent, cleanup := newTurnCoordTestLoop(t, errorPrv)
+6 -4
View File
@@ -105,9 +105,10 @@ func TestTurnProfile_DisabledPreservesDefaultHistoryAndPrompt(t *testing.T) {
al := newTurnProfileAgentLoop(t, cfg, provider)
agent := al.GetRegistry().GetDefaultAgent()
sessionKey := "agent:default:test-default"
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
initialHistory := []providers.Message{
{Role: "user", Content: "old user"},
{Role: "assistant", Content: "old assistant"},
{Role: "user", Content: "old user", CreatedAt: &ts},
{Role: "assistant", Content: "old assistant", CreatedAt: &ts},
}
agent.Sessions.SetHistory(sessionKey, initialHistory)
agent.Sessions.SetSummary(sessionKey, "old summary")
@@ -154,9 +155,10 @@ func TestTurnProfile_HistoryOffSuppressesHistoryAndPersistence(t *testing.T) {
al := newTurnProfileAgentLoop(t, cfg, provider)
agent := al.GetRegistry().GetDefaultAgent()
sessionKey := "agent:default:test-history-off"
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
initialHistory := []providers.Message{
{Role: "user", Content: "old user"},
{Role: "assistant", Content: "old assistant"},
{Role: "user", Content: "old user", CreatedAt: &ts},
{Role: "assistant", Content: "old assistant", CreatedAt: &ts},
}
agent.Sessions.SetHistory(sessionKey, initialHistory)
agent.Sessions.SetSummary(sessionKey, "old summary")
+78 -4
View File
@@ -643,13 +643,17 @@ func (ts *turnState) recordPersistedMessage(msg providers.Message) {
ts.persistedMessages = append(ts.persistedMessages, msg)
}
func (ts *turnState) persistedMessagesSnapshot() []providers.Message {
ts.mu.RLock()
defer ts.mu.RUnlock()
return append([]providers.Message(nil), ts.persistedMessages...)
}
func (ts *turnState) refreshRestorePointFromSession(agent *AgentInstance) {
history := agent.Sessions.GetHistory(ts.sessionKey)
summary := agent.Sessions.GetSummary(ts.sessionKey)
ts.mu.RLock()
persisted := append([]providers.Message(nil), ts.persistedMessages...)
ts.mu.RUnlock()
persisted := ts.persistedMessagesSnapshot()
if matched := matchingTurnMessageTail(history, persisted); matched > 0 {
history = append([]providers.Message(nil), history[:len(history)-matched]...)
@@ -689,13 +693,83 @@ func (ts *turnState) restoreSession(agent *AgentInstance) error {
func matchingTurnMessageTail(history, persisted []providers.Message) int {
maxMatch := min(len(history), len(persisted))
for size := maxMatch; size > 0; size-- {
if reflect.DeepEqual(history[len(history)-size:], persisted[len(persisted)-size:]) {
if messageSlicesEquivalent(history[len(history)-size:], persisted[len(persisted)-size:]) {
return size
}
}
return 0
}
func splitHistoryForActiveTurn(
history []providers.Message,
persisted []providers.Message,
) ([]providers.Message, []providers.Message) {
matched := matchingTurnMessageTail(history, persisted)
if matched <= 0 {
return append([]providers.Message(nil), history...), nil
}
stable := append([]providers.Message(nil), history[:len(history)-matched]...)
protected := append([]providers.Message(nil), history[len(history)-matched:]...)
return stable, protected
}
func messageSlicesEquivalent(a, b []providers.Message) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if !messagesEquivalent(a[i], b[i]) {
return false
}
}
return true
}
func messagesEquivalent(a, b providers.Message) bool {
return reflect.DeepEqual(normalizeMessageForComparison(a), normalizeMessageForComparison(b))
}
func normalizeMessageForComparison(msg providers.Message) providers.Message {
msg.PromptLayer = ""
msg.PromptSlot = ""
msg.PromptSource = ""
if len(msg.Media) == 0 {
msg.Media = nil
}
if len(msg.Attachments) == 0 {
msg.Attachments = nil
}
if len(msg.SystemParts) == 0 {
msg.SystemParts = nil
} else {
msg.SystemParts = append([]providers.ContentBlock(nil), msg.SystemParts...)
for i := range msg.SystemParts {
msg.SystemParts[i].PromptLayer = ""
msg.SystemParts[i].PromptSlot = ""
msg.SystemParts[i].PromptSource = ""
}
}
if len(msg.ToolCalls) == 0 {
msg.ToolCalls = nil
} else {
msg.ToolCalls = append([]providers.ToolCall(nil), msg.ToolCalls...)
for i := range msg.ToolCalls {
msg.ToolCalls[i].Name = ""
msg.ToolCalls[i].Arguments = nil
msg.ToolCalls[i].ThoughtSignature = ""
if msg.ToolCalls[i].Function != nil {
fn := *msg.ToolCalls[i].Function
fn.ThoughtSignature = ""
msg.ToolCalls[i].Function = &fn
}
}
}
return msg
}
func (ts *turnState) interruptHintMessage() providers.Message {
_, hint := ts.gracefulInterruptRequested()
content := "Interrupt requested. Stop scheduling tools and provide a short final summary."
+112
View File
@@ -0,0 +1,112 @@
package agent
import (
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/providers"
)
func TestMatchingTurnMessageTail_IgnoresInternalRuntimeFields(t *testing.T) {
history := []providers.Message{
{Role: "user", Content: "question"},
{
Role: "assistant",
ToolCalls: []providers.ToolCall{
{
ID: "call_1",
Type: "function",
Function: &providers.FunctionCall{
Name: "read_file",
Arguments: `{"path":"/tmp/test"}`,
},
},
},
},
}
persisted := []providers.Message{
userPromptMessage("question", nil),
{
Role: "assistant",
ToolCalls: []providers.ToolCall{
{
ID: "call_1",
Type: "function",
Name: "read_file",
Arguments: map[string]any{"path": "/tmp/test"},
ThoughtSignature: "internal-signature",
Function: &providers.FunctionCall{
Name: "read_file",
Arguments: `{"path":"/tmp/test"}`,
ThoughtSignature: "internal-signature",
},
},
},
},
}
if got := matchingTurnMessageTail(history, persisted); got != 2 {
t.Fatalf("matchingTurnMessageTail() = %d, want 2", got)
}
}
func TestSplitHistoryForActiveTurn_ProtectsPersistedTail(t *testing.T) {
history := []providers.Message{
{Role: "user", Content: "old question"},
{Role: "assistant", Content: "old answer"},
{Role: "user", Content: "current question"},
{Role: "tool", Content: "tool output", ToolCallID: "call_1"},
}
persisted := []providers.Message{
userPromptMessage("current question", nil),
{Role: "tool", Content: "tool output", ToolCallID: "call_1"},
}
stable, protected := splitHistoryForActiveTurn(history, persisted)
if len(stable) != 2 {
t.Fatalf("stable history len = %d, want 2", len(stable))
}
if len(protected) != 2 {
t.Fatalf("protected tail len = %d, want 2", len(protected))
}
if protected[0].Content != "current question" {
t.Fatalf("protected[0].Content = %q, want current question", protected[0].Content)
}
}
func TestTrimHistoryToFitContextWindow_WithProtectedTurnTailKeepsActiveTurn(t *testing.T) {
current := strings.Repeat("current turn ", 80)
history := []providers.Message{
{Role: "user", Content: strings.Repeat("old turn ", 60)},
{Role: "assistant", Content: strings.Repeat("old reply ", 60)},
{Role: "user", Content: current},
}
stable, protected := splitHistoryForActiveTurn(history, []providers.Message{
userPromptMessage(current, nil),
})
trimmedStable, messages, fit := trimHistoryToFitContextWindow(
stable,
func(trimmedHistory []providers.Message) []providers.Message {
return append(append([]providers.Message(nil), trimmedHistory...), protected...)
},
120,
nil,
0,
)
if fit {
t.Fatal("expected protected active turn alone to remain over budget")
}
if len(trimmedStable) != 0 {
t.Fatalf("trimmed stable history len = %d, want 0", len(trimmedStable))
}
if len(messages) != 1 {
t.Fatalf("messages len = %d, want 1 protected active-turn message", len(messages))
}
if messages[0].Content != current {
t.Fatalf("messages[0].Content = %q, want protected current turn", messages[0].Content)
}
}
+6 -4
View File
@@ -64,10 +64,12 @@ type OutboundScope struct {
// ContextUsage describes how much of the model's context window the current
// session consumes, and how far it is from triggering compression.
type ContextUsage struct {
UsedTokens int `json:"used_tokens"`
TotalTokens int `json:"total_tokens"` // model context window
CompressAtTokens int `json:"compress_at_tokens"` // threshold that triggers compression
UsedPercent int `json:"used_percent"` // 0-100
UsedTokens int `json:"used_tokens"`
TotalTokens int `json:"total_tokens"` // model context window
HistoryTokens int `json:"history_tokens"` // history-message tokens only (what maybeSummarize checks)
CompressAtTokens int `json:"compress_at_tokens"` // hard budget compression threshold (contextWindow - maxTokens)
SummarizeAtTokens int `json:"summarize_at_tokens"` // soft summarization trigger (vs history tokens)
UsedPercent int `json:"used_percent"` // 0-100, relative to compressAt
}
type OutboundMessage struct {
+37 -17
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
@@ -605,10 +606,11 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
scope := channels.BuildMediaScope("discord", m.ChannelID, m.ID)
// Helper to register a local file with the media store
storeMedia := func(localPath, filename string) string {
storeMedia := func(localPath string, attachment *discordgo.MessageAttachment) string {
if store := c.GetMediaStore(); store != nil {
ref, err := store.Store(localPath, media.MediaMeta{
Filename: filename,
Filename: attachment.Filename,
ContentType: attachment.ContentType,
Source: "discord",
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
}, scope)
@@ -620,22 +622,16 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
}
for _, attachment := range m.Attachments {
isAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType)
if isAudio {
localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
if localPath != "" {
mediaPaths = append(mediaPaths, storeMedia(localPath, attachment.Filename))
content = appendContent(content, fmt.Sprintf("[audio: %s]", attachment.Filename))
} else {
logger.WarnCF("discord", "Failed to download audio attachment", map[string]any{
"url": attachment.URL,
"filename": attachment.Filename,
})
mediaPaths = append(mediaPaths, attachment.URL)
content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
}
localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
if localPath != "" {
mediaPaths = append(mediaPaths, storeMedia(localPath, attachment))
tag := attachmentMediaTag(attachment.Filename, attachment.ContentType)
content = appendContent(content, fmt.Sprintf("[%s: %s]", tag, attachment.Filename))
} else {
logger.WarnCF("discord", "Failed to download attachment", map[string]any{
"url": attachment.URL,
"filename": attachment.Filename,
})
mediaPaths = append(mediaPaths, attachment.URL)
content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
}
@@ -748,6 +744,30 @@ func (c *DiscordChannel) downloadAttachment(url, filename string) string {
})
}
func attachmentMediaTag(filename, contentType string) string {
ct := strings.ToLower(contentType)
switch {
case strings.HasPrefix(ct, "image/"):
return "image"
case strings.HasPrefix(ct, "audio/"), ct == "application/ogg", ct == "application/x-ogg":
return "audio"
case strings.HasPrefix(ct, "video/"):
return "video"
}
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp":
return "image"
case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma":
return "audio"
case ".mp4", ".avi", ".mov", ".webm", ".mkv":
return "video"
}
return "file"
}
func applyDiscordProxy(session *discordgo.Session, proxyAddr string) error {
var proxyFunc func(*http.Request) (*url.URL, error)
if proxyAddr != "" {
+4 -4
View File
@@ -11,11 +11,11 @@ import (
func ClassifySendError(statusCode int, rawErr error) error {
switch {
case statusCode == http.StatusTooManyRequests:
return fmt.Errorf("%w: %v", ErrRateLimit, rawErr)
return fmt.Errorf("%w: %w", ErrRateLimit, rawErr)
case statusCode >= 500:
return fmt.Errorf("%w: %v", ErrTemporary, rawErr)
return fmt.Errorf("%w: %w", ErrTemporary, rawErr)
case statusCode >= 400:
return fmt.Errorf("%w: %v", ErrSendFailed, rawErr)
return fmt.Errorf("%w: %w", ErrSendFailed, rawErr)
default:
return rawErr
}
@@ -26,5 +26,5 @@ func ClassifyNetError(err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%w: %v", ErrTemporary, err)
return fmt.Errorf("%w: %w", ErrTemporary, err)
}
+36 -9
View File
@@ -52,6 +52,8 @@ type FeishuChannel struct {
progress *channels.ToolFeedbackAnimator
deleteMessageFn func(context.Context, string, string) error
sendMediaPartFn func(context.Context, string, bus.MediaPart, media.MediaStore) error
sendTextFn func(context.Context, string, string) (string, error)
}
type cachedMessage struct {
@@ -78,6 +80,8 @@ func NewFeishuChannel(bc *config.Channel, cfg *config.FeishuSettings, bus *bus.M
client: lark.NewClient(cfg.AppID, cfg.AppSecret.String(), opts...),
}
ch.deleteMessageFn = ch.deleteMessageAPI
ch.sendMediaPartFn = ch.sendMediaPart
ch.sendTextFn = ch.sendText
ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
ch.SetOwner(ch)
return ch, nil
@@ -304,7 +308,7 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str
}
req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(larkim.ReceiveIdTypeChatId).
ReceiveIdType(larkim.CreateMessageV1ReceiveIDTypeChatId).
Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(chatID).
MsgType(larkim.MsgTypeInteractive).
@@ -497,8 +501,16 @@ func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
}
caption := firstMediaCaption(msg.Parts)
sentAny := false
for _, part := range msg.Parts {
if err := c.sendMediaPart(ctx, msg.ChatID, part, store); err != nil {
if err := c.sendMediaPartFn(ctx, msg.ChatID, part, store); err != nil {
return nil, err
}
sentAny = true
}
if sentAny && caption != "" {
if _, err := c.sendTextFn(ctx, msg.ChatID, caption); err != nil {
return nil, err
}
}
@@ -557,6 +569,15 @@ func (c *FeishuChannel) sendMediaPart(
return nil
}
func firstMediaCaption(parts []bus.MediaPart) string {
for _, part := range parts {
if caption := strings.TrimSpace(part.Caption); caption != "" {
return caption
}
}
return ""
}
// --- Inbound message handling ---
func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
@@ -725,8 +746,8 @@ func (c *FeishuChannel) isBotMentioned(message *larkim.EventMessage) bool {
return false
}
knownID, _ := c.botOpenID.Load().(string)
if knownID == "" {
knownID, ok := c.botOpenID.Load().(string)
if !ok || knownID == "" {
logger.DebugCF("feishu", "Bot open_id unknown, cannot detect @mention", nil)
return false
}
@@ -992,7 +1013,13 @@ func (c *FeishuChannel) storeResourceFile(
})
return ""
}
out.Close()
if closeErr := out.Close(); closeErr != nil {
logger.ErrorCF("feishu", "Failed to close downloaded resource file", map[string]any{
"error": closeErr.Error(),
})
os.Remove(localPath)
return ""
}
ref, err := store.Store(localPath, media.MediaMeta{
Filename: filename,
@@ -1047,7 +1074,7 @@ func appendMediaTags(content, messageType string, mediaRefs []string) string {
// sendCard sends an interactive card message to a chat.
func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) (string, error) {
req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(larkim.ReceiveIdTypeChatId).
ReceiveIdType(larkim.CreateMessageV1ReceiveIDTypeChatId).
Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(chatID).
MsgType(larkim.MsgTypeInteractive).
@@ -1080,7 +1107,7 @@ func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) (stri
content, _ := json.Marshal(map[string]string{"text": text})
req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(larkim.ReceiveIdTypeChatId).
ReceiveIdType(larkim.CreateMessageV1ReceiveIDTypeChatId).
Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(chatID).
MsgType(larkim.MsgTypeText).
@@ -1134,7 +1161,7 @@ func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.F
// Send image message
content, _ := json.Marshal(map[string]string{"image_key": imageKey})
req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(larkim.ReceiveIdTypeChatId).
ReceiveIdType(larkim.CreateMessageV1ReceiveIDTypeChatId).
Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(chatID).
MsgType(larkim.MsgTypeImage).
@@ -1190,7 +1217,7 @@ func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.Fi
// Send file message
content, _ := json.Marshal(map[string]string{"file_key": fileKey})
req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(larkim.ReceiveIdTypeChatId).
ReceiveIdType(larkim.CreateMessageV1ReceiveIDTypeChatId).
Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(chatID).
MsgType(larkim.MsgTypeFile).
+39
View File
@@ -9,7 +9,9 @@ import (
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/media"
)
func TestExtractContent(t *testing.T) {
@@ -319,6 +321,43 @@ func TestFinalizeTrackedToolFeedbackMessage_ClearAfterSuccessfulEdit(t *testing.
}
}
func TestSendMedia_SendsCaptionFallbackAfterMedia(t *testing.T) {
ch := &FeishuChannel{
BaseChannel: channels.NewBaseChannel("feishu", nil, nil, nil),
progress: channels.NewToolFeedbackAnimator(nil),
}
ch.SetRunning(true)
ch.SetMediaStore(media.NewFileMediaStore())
var mediaOrder []string
var textCalls []string
ch.sendMediaPartFn = func(ctx context.Context, chatID string, part bus.MediaPart, store media.MediaStore) error {
mediaOrder = append(mediaOrder, part.Type)
return nil
}
ch.sendTextFn = func(ctx context.Context, chatID, text string) (string, error) {
textCalls = append(textCalls, chatID+"|"+text)
return "msg-1", nil
}
_, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "oc_123",
Parts: []bus.MediaPart{
{Type: "image", Caption: "shared caption"},
{Type: "file"},
},
})
if err != nil {
t.Fatalf("SendMedia() error = %v", err)
}
if len(mediaOrder) != 2 {
t.Fatalf("media sends = %v, want 2 sends", mediaOrder)
}
if len(textCalls) != 1 || textCalls[0] != "oc_123|shared caption" {
t.Fatalf("textCalls = %v, want [oc_123|shared caption]", textCalls)
}
}
func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) {
ch := &FeishuChannel{
progress: channels.NewToolFeedbackAnimator(nil),
+10 -2
View File
@@ -17,7 +17,7 @@ func toChannelHashes(cfg *config.Config) map[string]string {
_ = json.Unmarshal(marshal, &channelConfig)
for key, value := range channelConfig {
if !value["enabled"].(bool) {
if enabled, ok := value["enabled"].(bool); !ok || !enabled {
continue
}
hiddenValues(key, value, ch.Get(key))
@@ -94,7 +94,15 @@ func hiddenValues(key string, value map[string]any, ch *config.Channel) {
vv := value["webhooks"]
webhooks := make(map[string]string)
if vv != nil {
webhooks = vv.(map[string]string)
if m, ok := vv.(map[string]string); ok {
webhooks = m
} else if m, ok := vv.(map[string]any); ok {
for k, w := range m {
if s, ok := w.(string); ok {
webhooks[k] = s
}
}
}
}
if settings, ok := v.(*config.TeamsWebhookSettings); ok {
for name, target := range settings.Webhooks {
+54
View File
@@ -151,3 +151,57 @@ func TestToChannelHashes_RealWorldChannel(t *testing.T) {
assert.Equal(t, 1, len(h))
assert.Contains(t, h, "telegram")
}
func TestToChannelHashes_MissingEnabledKey(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Channels["test"] = &config.Channel{
Settings: config.RawNode(`{"key":"value"}`),
}
// Should not panic — the ok check safely handles the missing/false case
assert.NotPanics(t, func() {
_ = toChannelHashes(cfg)
})
h := toChannelHashes(cfg)
assert.Equal(t, 0, len(h), "channel with Enabled=false (default) skipped")
}
func TestToChannelHashes_EnabledNotBool(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Channels["test"] = &config.Channel{
Enabled: false,
Settings: config.RawNode(`{"enabled":"yes","boolField":true}`),
}
// Should not panic — string "enabled" won't match bool assertion, ok=false
assert.NotPanics(t, func() {
_ = toChannelHashes(cfg)
})
h := toChannelHashes(cfg)
assert.Equal(t, 0, len(h), "string enabled not treated as true")
}
func TestToChannelHashes_TeamsWebhookWithWebhooks(t *testing.T) {
cfg := config.DefaultConfig()
// teams_webhook with configured webhooks — this is the real-world
// scenario where the map type from JSON unmarshal (map[string]any)
// would cause a panic on the old unchecked vv.(map[string]string)
settings, _ := json.Marshal(map[string]any{
"enabled": true,
"webhooks": map[string]any{
"hook1": "https://example.com/webhook",
},
})
cfg.Channels["teams_webhook"] = &config.Channel{
Enabled: true,
Type: config.ChannelTeamsWebHook,
Settings: config.RawNode(settings),
}
assert.NotPanics(t, func() {
_ = toChannelHashes(cfg)
})
h := toChannelHashes(cfg)
assert.Equal(t, 1, len(h))
assert.Contains(t, h, "teams_webhook")
}
+1 -4
View File
@@ -995,7 +995,6 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
senderID := strconv.FormatInt(userID, 10)
var chatID string
var contextChatID string
var contextChatType string
metadata := map[string]string{}
@@ -1007,13 +1006,11 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
switch raw.MessageType {
case "private":
chatID = "private:" + senderID
contextChatID = senderID
contextChatType = "direct"
case "group":
groupIDStr := strconv.FormatInt(groupID, 10)
chatID = "group:" + groupIDStr
contextChatID = groupIDStr
contextChatType = "group"
metadata["group_id"] = groupIDStr
@@ -1080,7 +1077,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
inboundCtx := bus.InboundContext{
Channel: c.Name(),
ChatID: contextChatID,
ChatID: chatID,
ChatType: contextChatType,
SenderID: senderID,
MessageID: messageID,
+6 -4
View File
@@ -1394,10 +1394,12 @@ func setContextUsagePayload(payload map[string]any, u *bus.ContextUsage) {
return
}
payload["context_usage"] = map[string]any{
"used_tokens": u.UsedTokens,
"total_tokens": u.TotalTokens,
"compress_at_tokens": u.CompressAtTokens,
"used_percent": u.UsedPercent,
"used_tokens": u.UsedTokens,
"total_tokens": u.TotalTokens,
"history_tokens": u.HistoryTokens,
"compress_at_tokens": u.CompressAtTokens,
"summarize_at_tokens": u.SummarizeAtTokens,
"used_percent": u.UsedPercent,
}
}
+81 -4
View File
@@ -602,10 +602,12 @@ func TestBeginStream_FinalizeIncludesContextUsage(t *testing.T) {
t.Fatal("streamer should support FinalizeWithContext")
}
if err := contextStreamer.FinalizeWithContext(context.Background(), "final", &bus.ContextUsage{
UsedTokens: 10,
TotalTokens: 100,
CompressAtTokens: 80,
UsedPercent: 10,
UsedTokens: 10,
TotalTokens: 100,
HistoryTokens: 5,
CompressAtTokens: 80,
SummarizeAtTokens: 60,
UsedPercent: 10,
}); err != nil {
t.Fatalf("FinalizeWithContext() error = %v", err)
}
@@ -627,6 +629,12 @@ func TestBeginStream_FinalizeIncludesContextUsage(t *testing.T) {
if got := rawUsage["used_tokens"]; got != float64(10) {
t.Fatalf("used_tokens = %#v, want 10", got)
}
if got := rawUsage["history_tokens"]; got != float64(5) {
t.Fatalf("history_tokens = %#v, want 5", got)
}
if got := rawUsage["summarize_at_tokens"]; got != float64(60) {
t.Fatalf("summarize_at_tokens = %#v, want 60", got)
}
}
func TestCreateAndAddConnection_RespectsMaxConnectionsConcurrently(t *testing.T) {
@@ -835,6 +843,75 @@ func TestSendMedia_DismissesTrackedToolFeedbackMessage(t *testing.T) {
}
}
func TestSendMedia_IncludesCaptionAndAttachmentsInSinglePayload(t *testing.T) {
ch := newTestPicoChannel(t)
store := media.NewFileMediaStore()
ch.SetMediaStore(store)
if err := ch.Start(context.Background()); err != nil {
t.Fatalf("Start() error = %v", err)
}
defer ch.Stop(context.Background())
clientConn, received, cleanup := newTestPicoWebSocket(t)
defer cleanup()
ch.addConnForTest(&picoConn{id: "conn-1", conn: clientConn, sessionID: "sess-1"})
localPath := filepath.Join(t.TempDir(), "photo.png")
if err := os.WriteFile(localPath, []byte("png-body"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
ref, err := store.Store(localPath, media.MediaMeta{
Filename: "photo.png",
ContentType: "image/png",
}, "test-scope")
if err != nil {
t.Fatalf("Store() error = %v", err)
}
_, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "pico:sess-1",
Parts: []bus.MediaPart{{
Ref: ref,
Type: "image",
Filename: "photo.png",
ContentType: "image/png",
Caption: "recipe translation",
}},
})
if err != nil {
t.Fatalf("SendMedia() error = %v", err)
}
select {
case msg := <-received:
if msg.Type != TypeMessageCreate {
t.Fatalf("message type = %q, want %q", msg.Type, TypeMessageCreate)
}
payload := msg.Payload
if got := payload[PayloadKeyContent]; got != "recipe translation" {
t.Fatalf("content = %#v, want %q", got, "recipe translation")
}
rawAttachments, ok := payload["attachments"].([]any)
if !ok || len(rawAttachments) != 1 {
t.Fatalf("attachments = %#v, want 1 attachment", payload["attachments"])
}
attachment, ok := rawAttachments[0].(map[string]any)
if !ok {
t.Fatalf("attachment = %#v, want map", rawAttachments[0])
}
if got := attachment["type"]; got != "image" {
t.Fatalf("attachment type = %#v, want image", got)
}
if got := attachment["filename"]; got != "photo.png" {
t.Fatalf("attachment filename = %#v, want photo.png", got)
}
case <-time.After(time.Second):
t.Fatal("expected media payload to be delivered")
}
}
func TestPicoDownloadURLForRef(t *testing.T) {
got, err := picoDownloadURLForRef("media://attachment-1")
if err != nil {
+37 -2
View File
@@ -29,6 +29,8 @@ type SlackChannel struct {
ctx context.Context
cancel context.CancelFunc
pendingAcks sync.Map
uploadFileFn func(context.Context, slack.UploadFileParameters) error
postTextFn func(context.Context, string, string, string) error
}
type slackMessageRef struct {
@@ -63,6 +65,18 @@ func NewSlackChannel(
config: cfg,
api: api,
socketClient: socketClient,
uploadFileFn: func(ctx context.Context, params slack.UploadFileParameters) error {
_, err := api.UploadFileContext(ctx, params)
return err
},
postTextFn: func(ctx context.Context, channelID, threadTS, text string) error {
opts := []slack.MsgOption{slack.MsgOptionText(text, false)}
if threadTS != "" {
opts = append(opts, slack.MsgOptionTS(threadTS))
}
_, _, err := api.PostMessageContext(ctx, channelID, opts...)
return err
},
}, nil
}
@@ -140,7 +154,10 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]str
}
if ref, ok := c.pendingAcks.LoadAndDelete(deliveryChatID); ok {
msgRef := ref.(slackMessageRef)
msgRef, ok := ref.(slackMessageRef)
if !ok {
return []string{ts}, nil
}
c.api.AddReaction("white_check_mark", slack.ItemRef{
Channel: msgRef.ChannelID,
Timestamp: msgRef.Timestamp,
@@ -171,6 +188,8 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
}
caption := slackFirstMediaCaption(msg.Parts)
sentAny := false
for _, part := range msg.Parts {
localPath, err := store.Resolve(part.Ref)
if err != nil {
@@ -191,7 +210,7 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
title = filename
}
_, err = c.api.UploadFileContext(ctx, slack.UploadFileParameters{
err = c.uploadFileFn(ctx, slack.UploadFileParameters{
Channel: channelID,
ThreadTimestamp: threadTS,
File: localPath,
@@ -205,6 +224,13 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
})
return nil, fmt.Errorf("slack send media: %w", channels.ErrTemporary)
}
sentAny = true
}
if sentAny && caption != "" {
if err := c.postTextFn(ctx, channelID, threadTS, caption); err != nil {
return nil, fmt.Errorf("slack send media caption fallback: %w", channels.ErrTemporary)
}
}
// UploadFile does not expose the posted message timestamp in its
@@ -212,6 +238,15 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
return nil, nil
}
func slackFirstMediaCaption(parts []bus.MediaPart) string {
for _, part := range parts {
if caption := strings.TrimSpace(part.Caption); caption != "" {
return caption
}
}
return ""
}
// ReactToMessage implements channels.ReactionCapable.
// It adds an "eyes" (👀) reaction to the inbound message and returns an undo function
// that removes the reaction.
+78
View File
@@ -1,10 +1,17 @@
package slack
import (
"context"
"os"
"path/filepath"
"testing"
slacksdk "github.com/slack-go/slack"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/media"
)
func TestParseSlackChatID(t *testing.T) {
@@ -184,3 +191,74 @@ func TestSlackChannelIsAllowed(t *testing.T) {
}
})
}
func TestSendMedia_SendsCaptionFallbackAfterUploads(t *testing.T) {
ch := &SlackChannel{
BaseChannel: channels.NewBaseChannel("slack", nil, nil, nil),
}
ch.SetRunning(true)
store := media.NewFileMediaStore()
ch.SetMediaStore(store)
tmpDir := t.TempDir()
localPath := filepath.Join(tmpDir, "report.txt")
if err := os.WriteFile(localPath, []byte("attachment body"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
ref, err := store.Store(localPath, media.MediaMeta{
Filename: "report.txt",
ContentType: "text/plain",
}, "test-scope")
if err != nil {
t.Fatalf("Store() error = %v", err)
}
var uploaded []slackUploadRecord
var posted []string
ch.uploadFileFn = func(ctx context.Context, params slacksdk.UploadFileParameters) error {
uploaded = append(uploaded, slackUploadRecord{
Channel: params.Channel,
Thread: params.ThreadTimestamp,
File: params.File,
Name: params.Filename,
Title: params.Title,
})
return nil
}
ch.postTextFn = func(ctx context.Context, channelID, threadTS, text string) error {
posted = append(posted, channelID+"|"+threadTS+"|"+text)
return nil
}
_, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "C123456/1234567890.123456",
Parts: []bus.MediaPart{{
Ref: ref,
Type: "file",
Filename: "report.txt",
ContentType: "text/plain",
Caption: "shared caption",
}},
})
if err != nil {
t.Fatalf("SendMedia() error = %v", err)
}
if len(uploaded) != 1 {
t.Fatalf("uploads = %v, want 1 upload", uploaded)
}
if uploaded[0].Title != "shared caption" {
t.Fatalf("upload title = %q, want shared caption", uploaded[0].Title)
}
if len(posted) != 1 || posted[0] != "C123456|1234567890.123456|shared caption" {
t.Fatalf("posted = %v, want fallback text in same thread", posted)
}
}
type slackUploadRecord struct {
Channel string
Thread string
File string
Name string
Title string
}
+187 -1
View File
@@ -44,7 +44,10 @@ var (
reInlineCode = regexp.MustCompile("`([^`]+)`")
)
const defaultMediaGroupDelay = 500 * time.Millisecond
const (
defaultMediaGroupDelay = 500 * time.Millisecond
telegramCaptionLimit = 1024
)
type TelegramChannel struct {
*channels.BaseChannel
@@ -639,6 +642,34 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
}
var messageIDs []string
leadingCaption := telegramLeadingCaption(msg.Parts)
if len([]rune(leadingCaption)) > telegramCaptionLimit {
leadingIDs, leadingErr := c.sendCaptionText(ctx, chatID, threadID, leadingCaption)
if leadingErr != nil {
return nil, leadingErr
}
messageIDs = append(messageIDs, leadingIDs...)
msg = telegramClearMediaCaptions(msg)
}
if len(msg.Parts) > 1 && telegramCanSendMediaGroup(msg.Parts) {
groupIDs, err := c.sendImageMediaGroups(ctx, chatID, threadID, store, msg.Parts)
if err != nil {
logger.ErrorCF("telegram", "Failed to send media group", map[string]any{
"count": len(msg.Parts),
"error": err.Error(),
})
return nil, fmt.Errorf("telegram send media group: %w", channels.ErrTemporary)
}
if len(groupIDs) > 0 {
messageIDs = append(messageIDs, groupIDs...)
if hasTrackedMsg {
c.dismissTrackedToolFeedbackMessage(ctx, trackedChatID, trackedMsgID)
}
return messageIDs, nil
}
}
for _, part := range msg.Parts {
localPath, err := store.Resolve(part.Ref)
if err != nil {
@@ -742,6 +773,154 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
return messageIDs, nil
}
func telegramCanSendMediaGroup(parts []bus.MediaPart) bool {
if len(parts) < 2 {
return false
}
for _, part := range parts {
if part.Type != "image" {
return false
}
}
return true
}
func (c *TelegramChannel) sendImageMediaGroups(
ctx context.Context,
chatID int64,
threadID int,
store media.MediaStore,
parts []bus.MediaPart,
) ([]string, error) {
const maxGroupSize = 10
messageIDs := make([]string, 0, len(parts))
for start := 0; start < len(parts); start += maxGroupSize {
end := start + maxGroupSize
if end > len(parts) {
end = len(parts)
}
groupIDs, err := c.sendSingleImageMediaGroup(ctx, chatID, threadID, store, parts[start:end])
if err != nil {
return nil, err
}
messageIDs = append(messageIDs, groupIDs...)
}
return messageIDs, nil
}
func (c *TelegramChannel) sendSingleImageMediaGroup(
ctx context.Context,
chatID int64,
threadID int,
store media.MediaStore,
parts []bus.MediaPart,
) ([]string, error) {
opened := make([]*os.File, 0, len(parts))
defer func() {
for _, file := range opened {
file.Close()
}
}()
inputMedia := make([]telego.InputMedia, 0, len(parts))
for i, part := range parts {
localPath, err := store.Resolve(part.Ref)
if err != nil {
logger.ErrorCF("telegram", "Failed to resolve media ref for media group", map[string]any{
"ref": part.Ref,
"error": err.Error(),
})
return nil, err
}
file, err := os.Open(localPath)
if err != nil {
logger.ErrorCF("telegram", "Failed to open media file for media group", map[string]any{
"path": localPath,
"error": err.Error(),
})
return nil, err
}
opened = append(opened, file)
mediaItem := &telego.InputMediaPhoto{
Type: telego.MediaTypePhoto,
Media: telego.InputFile{File: file},
}
if i == 0 {
mediaItem.Caption = part.Caption
}
inputMedia = append(inputMedia, mediaItem)
}
results, err := c.bot.SendMediaGroup(ctx, &telego.SendMediaGroupParams{
ChatID: tu.ID(chatID),
MessageThreadID: threadID,
Media: inputMedia,
})
if err != nil {
return nil, err
}
messageIDs := make([]string, 0, len(results))
for _, result := range results {
messageIDs = append(messageIDs, strconv.Itoa(result.MessageID))
}
return messageIDs, nil
}
func (c *TelegramChannel) sendCaptionText(
ctx context.Context,
chatID int64,
threadID int,
text string,
) ([]string, error) {
text = strings.TrimSpace(text)
if text == "" {
return nil, nil
}
chunks := channels.SplitMessage(text, c.MaxMessageLength())
messageIDs := make([]string, 0, len(chunks))
for _, chunk := range chunks {
chunk = strings.TrimSpace(chunk)
if chunk == "" {
continue
}
msgID, err := c.sendChunk(ctx, sendChunkParams{
chatID: chatID,
threadID: threadID,
content: chunk,
mdFallback: chunk,
useMarkdownV2: false,
})
if err != nil {
return nil, err
}
messageIDs = append(messageIDs, msgID)
}
return messageIDs, nil
}
func telegramLeadingCaption(parts []bus.MediaPart) string {
if len(parts) == 0 {
return ""
}
return strings.TrimSpace(parts[0].Caption)
}
func telegramClearMediaCaptions(msg bus.OutboundMediaMessage) bus.OutboundMediaMessage {
if len(msg.Parts) == 0 {
return msg
}
cloned := msg
cloned.Parts = append([]bus.MediaPart(nil), msg.Parts...)
for i := range cloned.Parts {
cloned.Parts[i].Caption = ""
}
return cloned
}
func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
if message != nil && strings.TrimSpace(message.MediaGroupID) != "" {
return c.bufferMediaGroupMessage(ctx, message)
@@ -1048,6 +1227,13 @@ func (c *TelegramChannel) collectTelegramMessageParts(
if caption := strings.TrimSpace(msg.Caption); caption != "" {
parts.content = append(parts.content, caption)
}
if msg.Location != nil {
parts.content = append(parts.content, fmt.Sprintf(
"[User location: lat=%.6f, lng=%.6f]",
msg.Location.Latitude,
msg.Location.Longitude,
))
}
if len(msg.Photo) > 0 {
photo := msg.Photo[len(msg.Photo)-1]
photoPath := c.downloadPhoto(ctx, photo.FileID)
+317
View File
@@ -110,6 +110,17 @@ func successResponseWithMessageID(t *testing.T, messageID int) *ta.Response {
return &ta.Response{Ok: true, Result: b}
}
func successMediaGroupResponse(t *testing.T, messageIDs ...int) *ta.Response {
t.Helper()
messages := make([]telego.Message, 0, len(messageIDs))
for _, messageID := range messageIDs {
messages = append(messages, telego.Message{MessageID: messageID})
}
b, err := json.Marshal(messages)
require.NoError(t, err)
return &ta.Response{Ok: true, Result: b}
}
func successUserResponse(t *testing.T, user *telego.User) *ta.Response {
t.Helper()
b, err := json.Marshal(user)
@@ -237,6 +248,276 @@ func TestSendMedia_ImageNonDimensionErrorDoesNotFallback(t *testing.T) {
assert.NotContains(t, caller.calls[0].URL, "sendDocument")
}
func TestSendMedia_MultipleImagesUseMediaGroup(t *testing.T) {
constructor := &multipartRecordingConstructor{}
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
if strings.Contains(url, "sendMediaGroup") {
return successMediaGroupResponse(t, 101, 102), nil
}
t.Fatalf("unexpected API call: %s", url)
return nil, nil
},
}
ch := newTestChannelWithConstructor(t, caller, constructor)
store := media.NewFileMediaStore()
ch.SetMediaStore(store)
tmpDir := t.TempDir()
firstPath := filepath.Join(tmpDir, "first.png")
secondPath := filepath.Join(tmpDir, "second.png")
require.NoError(t, os.WriteFile(firstPath, []byte("first-image"), 0o644))
require.NoError(t, os.WriteFile(secondPath, []byte("second-image"), 0o644))
firstRef, err := store.Store(firstPath, media.MediaMeta{Filename: "first.png", ContentType: "image/png"}, "scope-1")
require.NoError(t, err)
secondRef, err := store.Store(
secondPath,
media.MediaMeta{Filename: "second.png", ContentType: "image/png"},
"scope-1",
)
require.NoError(t, err)
ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "12345",
Parts: []bus.MediaPart{
{Type: "image", Ref: firstRef, Caption: "album caption"},
{Type: "image", Ref: secondRef},
},
})
require.NoError(t, err)
assert.Equal(t, []string{"101", "102"}, ids)
require.Len(t, caller.calls, 1)
assert.Contains(t, caller.calls[0].URL, "sendMediaGroup")
require.Len(t, constructor.calls, 1)
require.Len(t, constructor.calls[0].FileSizes, 2)
var mediaPayload []map[string]any
require.NoError(t, json.Unmarshal([]byte(constructor.calls[0].Parameters["media"]), &mediaPayload))
require.Len(t, mediaPayload, 2)
assert.Equal(t, "album caption", mediaPayload[0]["caption"])
_, hasSecondCaption := mediaPayload[1]["caption"]
assert.False(t, hasSecondCaption)
}
func TestSendMedia_MoreThanTenImagesSplitIntoMediaGroups(t *testing.T) {
constructor := &multipartRecordingConstructor{}
callIndex := 0
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
if !strings.Contains(url, "sendMediaGroup") {
t.Fatalf("unexpected API call: %s", url)
}
callIndex++
if callIndex == 1 {
return successMediaGroupResponse(t, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010), nil
}
if callIndex == 2 {
return successMediaGroupResponse(t, 1011, 1012, 1013, 1014, 1015), nil
}
t.Fatalf("unexpected sendMediaGroup call #%d", callIndex)
return nil, nil
},
}
ch := newTestChannelWithConstructor(t, caller, constructor)
store := media.NewFileMediaStore()
ch.SetMediaStore(store)
tmpDir := t.TempDir()
parts := make([]bus.MediaPart, 0, 15)
for i := 0; i < 15; i++ {
path := filepath.Join(tmpDir, "image-"+strconv.Itoa(i)+".png")
require.NoError(t, os.WriteFile(path, []byte("img-"+strconv.Itoa(i)), 0o644))
ref, err := store.Store(
path,
media.MediaMeta{Filename: filepath.Base(path), ContentType: "image/png"},
"scope-1",
)
require.NoError(t, err)
part := bus.MediaPart{Type: "image", Ref: ref}
if i == 0 {
part.Caption = "long album caption"
}
parts = append(parts, part)
}
ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "12345",
Parts: parts,
})
require.NoError(t, err)
assert.Equal(t, []string{
"1001", "1002", "1003", "1004", "1005",
"1006", "1007", "1008", "1009", "1010",
"1011", "1012", "1013", "1014", "1015",
}, ids)
require.Len(t, caller.calls, 2)
require.Len(t, constructor.calls, 2)
}
func TestSendMedia_SingleImageLongCaptionSendsTextFirst(t *testing.T) {
constructor := &multipartRecordingConstructor{}
longCaption := strings.Repeat("a", telegramCaptionLimit) + " tail overflow"
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
switch {
case strings.Contains(url, "sendMessage"):
return successResponseWithMessageID(t, 201), nil
case strings.Contains(url, "sendPhoto"):
return successResponseWithMessageID(t, 202), nil
default:
t.Fatalf("unexpected API call: %s", url)
return nil, nil
}
},
}
ch := newTestChannelWithConstructor(t, caller, constructor)
store := media.NewFileMediaStore()
ch.SetMediaStore(store)
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "image.png")
require.NoError(t, os.WriteFile(path, []byte("img"), 0o644))
ref, err := store.Store(path, media.MediaMeta{Filename: "image.png", ContentType: "image/png"}, "scope-1")
require.NoError(t, err)
ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "12345",
Parts: []bus.MediaPart{{
Type: "image",
Ref: ref,
Caption: longCaption,
}},
})
require.NoError(t, err)
assert.Equal(t, []string{"201", "202"}, ids)
require.Len(t, caller.calls, 2)
assert.Contains(t, caller.calls[0].URL, "sendMessage")
assert.Contains(t, caller.calls[1].URL, "sendPhoto")
assert.Equal(t, "", constructor.calls[0].Parameters["caption"])
}
func TestSendMedia_MediaGroupLongCaptionSendsTextFirst(t *testing.T) {
constructor := &multipartRecordingConstructor{}
longCaption := strings.Repeat("b", telegramCaptionLimit) + " trailing explanation"
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
switch {
case strings.Contains(url, "sendMessage"):
return successResponseWithMessageID(t, 301), nil
case strings.Contains(url, "sendMediaGroup"):
return successMediaGroupResponse(t, 302, 303), nil
default:
t.Fatalf("unexpected API call: %s", url)
return nil, nil
}
},
}
ch := newTestChannelWithConstructor(t, caller, constructor)
store := media.NewFileMediaStore()
ch.SetMediaStore(store)
tmpDir := t.TempDir()
firstPath := filepath.Join(tmpDir, "first.png")
secondPath := filepath.Join(tmpDir, "second.png")
require.NoError(t, os.WriteFile(firstPath, []byte("first-image"), 0o644))
require.NoError(t, os.WriteFile(secondPath, []byte("second-image"), 0o644))
firstRef, err := store.Store(firstPath, media.MediaMeta{Filename: "first.png", ContentType: "image/png"}, "scope-1")
require.NoError(t, err)
secondRef, err := store.Store(
secondPath,
media.MediaMeta{Filename: "second.png", ContentType: "image/png"},
"scope-1",
)
require.NoError(t, err)
ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "12345",
Parts: []bus.MediaPart{
{Type: "image", Ref: firstRef, Caption: longCaption},
{Type: "image", Ref: secondRef},
},
})
require.NoError(t, err)
assert.Equal(t, []string{"301", "302", "303"}, ids)
require.Len(t, caller.calls, 2)
assert.Contains(t, caller.calls[0].URL, "sendMessage")
assert.Contains(t, caller.calls[1].URL, "sendMediaGroup")
}
func TestSendMedia_MultiGroupLongCaptionSendsTextBeforeGroups(t *testing.T) {
constructor := &multipartRecordingConstructor{}
longCaption := strings.Repeat("c", telegramCaptionLimit) + " overflow before second album"
callOrder := make([]string, 0, 3)
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
switch {
case strings.Contains(url, "sendMessage"):
callOrder = append(callOrder, "text")
return successResponseWithMessageID(t, 499), nil
case strings.Contains(url, "sendMediaGroup"):
callOrder = append(callOrder, "group")
if len(callOrder) == 2 {
return successMediaGroupResponse(t, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410), nil
}
if len(callOrder) == 3 {
return successMediaGroupResponse(t, 411, 412, 413, 414, 415), nil
}
t.Fatalf("unexpected sendMediaGroup order: %v", callOrder)
return nil, nil
default:
t.Fatalf("unexpected API call: %s", url)
return nil, nil
}
},
}
ch := newTestChannelWithConstructor(t, caller, constructor)
store := media.NewFileMediaStore()
ch.SetMediaStore(store)
tmpDir := t.TempDir()
parts := make([]bus.MediaPart, 0, 15)
for i := 0; i < 15; i++ {
path := filepath.Join(tmpDir, "image-"+strconv.Itoa(i)+".png")
require.NoError(t, os.WriteFile(path, []byte("img-"+strconv.Itoa(i)), 0o644))
ref, err := store.Store(
path,
media.MediaMeta{Filename: filepath.Base(path), ContentType: "image/png"},
"scope-1",
)
require.NoError(t, err)
part := bus.MediaPart{Type: "image", Ref: ref}
if i == 0 {
part.Caption = longCaption
}
parts = append(parts, part)
}
ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "12345",
Parts: parts,
})
require.NoError(t, err)
assert.Equal(t, []string{
"499",
"401", "402", "403", "404", "405",
"406", "407", "408", "409", "410",
"411", "412", "413", "414", "415",
}, ids)
assert.Equal(t, []string{"text", "group", "group"}, callOrder)
}
func TestSend_EmptyContent(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
@@ -1216,6 +1497,42 @@ func TestHandleMessage_EmptyContent_Ignored(t *testing.T) {
}
}
func TestHandleMessage_LocationForwardedAsText(t *testing.T) {
messageBus := bus.NewMessageBus()
ch := &TelegramChannel{
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
chatIDs: make(map[string]int64),
ctx: context.Background(),
}
msg := &telego.Message{
MessageID: 3049,
Location: &telego.Location{
Latitude: 35.197713,
Longitude: 136.885705,
},
Chat: telego.Chat{
ID: 456,
Type: "private",
},
From: &telego.User{
ID: 789,
FirstName: "User",
},
}
err := ch.handleMessage(context.Background(), msg)
require.NoError(t, err)
select {
case inbound := <-messageBus.InboundChan():
assert.Equal(t, "[User location: lat=35.197713, lng=136.885705]", inbound.Content)
assert.Equal(t, "3049", inbound.Context.MessageID)
case <-time.After(time.Second):
t.Fatal("timed out waiting for location message")
}
}
func TestHandleMessage_MediaGroupCombinesCaptionMessages(t *testing.T) {
messageBus, ch := newMediaGroupTestChannel(10 * time.Millisecond)
base := testMediaGroupMessage("album-1")
+59
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
@@ -319,3 +320,61 @@ func TestSelectInboundMediaItemFallsBackToRefMessage(t *testing.T) {
t.Fatalf("selectInboundMediaItem().Type = %d, want %d", item.Type, MessageItemTypeImage)
}
}
func TestSendUploadedMedia_SendsCaptionAsSeparateTextBeforeMedia(t *testing.T) {
var requests []SendMessageReq
ch := &WeixinChannel{
api: &ApiClient{
BaseURL: "https://ilinkai.weixin.qq.com/",
HttpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path != "/ilink/bot/sendmessage" {
t.Fatalf("sendmessage path = %q, want /ilink/bot/sendmessage", r.URL.Path)
}
var req SendMessageReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode sendmessage req: %v", err)
}
requests = append(requests, req)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader([]byte(`{"ret":0,"errcode":0}`))),
Header: make(http.Header),
}, nil
})},
},
typingCache: make(map[string]typingTicketCacheEntry),
}
err := ch.sendUploadedMedia(
context.Background(),
"user-1",
"ctx-1",
"recipe translation",
UploadMediaTypeImage,
&uploadedFileInfo{
downloadParam: "download-token",
aesKeyHex: "31323334353637383930616263646566",
fileSize: 11,
cipherSize: 16,
filename: "photo.png",
},
)
if err != nil {
t.Fatalf("sendUploadedMedia() error = %v", err)
}
if len(requests) != 2 {
t.Fatalf("sendUploadedMedia() sent %d requests, want 2", len(requests))
}
if len(requests[0].Msg.ItemList) != 1 || requests[0].Msg.ItemList[0].Type != MessageItemTypeText {
t.Fatalf("first request item = %+v, want text item", requests[0].Msg.ItemList)
}
if got := requests[0].Msg.ItemList[0].TextItem.Text; got != "recipe translation" {
t.Fatalf("first request text = %q, want recipe translation", got)
}
if len(requests[1].Msg.ItemList) != 1 || requests[1].Msg.ItemList[0].Type != MessageItemTypeImage {
t.Fatalf("second request item = %+v, want image item", requests[1].Msg.ItemList)
}
if requests[1].Msg.ItemList[0].ImageItem == nil || requests[1].Msg.ItemList[0].ImageItem.Media == nil {
t.Fatalf("second request image media = %+v, want media ref", requests[1].Msg.ItemList[0].ImageItem)
}
}
@@ -269,9 +269,9 @@ func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error {
}
func (c *WhatsAppNativeChannel) eventHandler(evt any) {
switch evt.(type) {
switch v := evt.(type) {
case *events.Message:
c.handleIncoming(evt.(*events.Message))
c.handleIncoming(v)
case *events.Disconnected:
logger.InfoCF("whatsapp", "WhatsApp disconnected, will attempt reconnection", nil)
c.reconnectMu.Lock()
+5 -2
View File
@@ -29,14 +29,17 @@ func formatContextStats(s *ContextStats) string {
remaining = 0
}
usedWindowPercent := s.UsedTokens * 100 / max(s.TotalTokens, 1)
return fmt.Sprintf(
"Context usage \nMessages: %d \nUsed: ~%d / %d tokens (%d%%) \nCompress at: %d tokens \nCompression progress: %d%% \nRemaining: ~%d tokens",
msg := fmt.Sprintf(
"Context usage \nMessages: %d \nUsed: ~%d / %d tokens (%d%%) \nHistory: ~%d tokens \nCompress at: %d tokens \nSummarize at: %d tokens \nCompression progress: %d%% \nRemaining: ~%d tokens",
s.MessageCount,
s.UsedTokens,
s.TotalTokens,
usedWindowPercent,
s.HistoryTokens,
s.CompressAtTokens,
s.SummarizeAtTokens,
s.UsedPercent,
remaining,
)
return msg
}
+7 -5
View File
@@ -29,11 +29,13 @@ type MCPToolInfo struct {
// ContextStats describes current session context window usage.
type ContextStats struct {
UsedTokens int
TotalTokens int // model context window
CompressAtTokens int // compression threshold
UsedPercent int // 0-100
MessageCount int
UsedTokens int
TotalTokens int // model context window
HistoryTokens int // history-only tokens (what maybeSummarize checks)
CompressAtTokens int // hard budget compression threshold
SummarizeAtTokens int // soft summarization trigger
UsedPercent int // 0-100
MessageCount int
}
// StopResult describes the outcome of a stop request for the current session.
+84 -2
View File
@@ -7,6 +7,7 @@ import (
"math/rand"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"sync/atomic"
@@ -194,6 +195,9 @@ type ExposePath struct {
// Uses strings.Replacer for O(n+m) performance (computed once per SecurityConfig).
// Short content (below FilterMinLength) is returned unchanged for performance.
func (c *Config) FilterSensitiveData(content string) string {
if c == nil {
return content
}
// Check if filtering is enabled (default: true)
if !c.Tools.IsFilterSensitiveDataEnabled() {
return content
@@ -254,7 +258,7 @@ func (c *Config) MarshalJSON() ([]byte, error) {
Alias: (*Alias)(c),
}
if len(c.Session.Dimensions) > 0 || len(c.Session.IdentityLinks) > 0 {
if len(c.Session.Dimensions) > 0 || len(c.Session.IdentityLinks) > 0 || c.Session.DmScope != "" {
sessionCfg := c.Session
aux.Session = &sessionCfg
}
@@ -346,6 +350,47 @@ type DispatchSelector struct {
type SessionConfig struct {
Dimensions []string `json:"dimensions,omitempty"`
IdentityLinks map[string][]string `json:"identity_links,omitempty"`
DmScope string `json:"dm_scope,omitempty"`
}
// ApplyDmScope translates the user-facing dm_scope value into the internal
// dimensions array that the routing layer consumes. It is a no-op when
// DmScope is empty or when Dimensions is already set (explicit Dimensions
// take precedence over the derived value).
func (s *SessionConfig) ApplyDmScope() {
if s.DmScope == "" || len(s.Dimensions) > 0 {
return
}
switch s.DmScope {
case "per-channel-peer":
s.Dimensions = []string{"chat", "sender"}
case "per-channel":
s.Dimensions = []string{"chat"}
case "per-peer":
s.Dimensions = []string{"sender"}
case "global":
s.Dimensions = nil
}
}
// DeriveDmScope sets DmScope based on Dimensions when DmScope is empty.
// This handles legacy/fresh configs that only have explicit Dimensions
// without a corresponding DmScope value, ensuring the API response always
// includes a dm_scope that matches the actual runtime dimensions.
func (s *SessionConfig) DeriveDmScope() {
if s.DmScope != "" || len(s.Dimensions) == 0 {
return
}
switch {
case slices.Equal(s.Dimensions, []string{"chat", "sender"}):
s.DmScope = "per-channel-peer"
case slices.Equal(s.Dimensions, []string{"chat"}):
s.DmScope = "per-channel"
case slices.Equal(s.Dimensions, []string{"sender"}):
s.DmScope = "per-peer"
}
// Dimensions not matching any known scope mapping (custom array)
// is fine — DmScope stays empty and the UI can handle it.
}
// RoutingConfig controls the intelligent model routing feature.
@@ -814,6 +859,12 @@ type ToolConfig struct {
Enabled bool `json:"enabled" yaml:"-" env:"ENABLED"`
}
type MessageToolsConfig struct {
ToolConfig `yaml:"-" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
MediaEnabled bool `json:"media_enabled" yaml:"-" env:"PICOCLAW_TOOLS_MESSAGE_MEDIA_ENABLED"`
}
type BraveConfig struct {
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"`
@@ -865,6 +916,31 @@ func (c *TavilyConfig) SetAPIKeys(keys []string) {
}
}
type KagiConfig struct {
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_KAGI_ENABLED"`
APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty" env:"PICOCLAW_TOOLS_WEB_KAGI_API_KEYS"`
BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_TOOLS_WEB_KAGI_BASE_URL"`
MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_KAGI_MAX_RESULTS"`
}
// APIKey returns the Kagi API key
func (c *KagiConfig) APIKey() string {
if len(c.APIKeys) == 0 {
return ""
}
return c.APIKeys[0].String()
}
// SetAPIKey sets the Kagi API key
func (c *KagiConfig) SetAPIKey(key string) {
c.APIKeys = SimpleSecureStrings(key)
}
// SetAPIKeys sets the Kagi API keys
func (c *KagiConfig) SetAPIKeys(keys []string) {
c.APIKeys = SimpleSecureStrings(keys...)
}
type DuckDuckGoConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
@@ -928,6 +1004,7 @@ type WebToolsConfig struct {
ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_"`
Brave BraveConfig `yaml:"brave,omitempty" json:"brave"`
Tavily TavilyConfig `yaml:"tavily,omitempty" json:"tavily"`
Kagi KagiConfig `yaml:"kagi,omitempty" json:"kagi"`
Sogou SogouConfig `yaml:"-" json:"sogou"`
DuckDuckGo DuckDuckGoConfig `yaml:"-" json:"duckduckgo"`
Gemini GeminiSearchConfig `yaml:"gemini,omitempty" json:"gemini"`
@@ -1026,7 +1103,7 @@ type ToolsConfig struct {
InstallSkill ToolConfig `json:"install_skill" yaml:"-" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
ListDir ToolConfig `json:"list_dir" yaml:"-" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
LoadImage ToolConfig `json:"load_image" yaml:"-" envPrefix:"PICOCLAW_TOOLS_LOAD_IMAGE_"`
Message ToolConfig `json:"message" yaml:"-" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
Message MessageToolsConfig `json:"message" yaml:"-"`
ReadFile ReadFileToolConfig `json:"read_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
Serial ToolConfig `json:"serial" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SERIAL_"`
SendFile ToolConfig `json:"send_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
@@ -1441,6 +1518,9 @@ func LoadConfig(path string) (*Config, error) {
cfg.Agents.Defaults.Workspace = filepath.Join(homePath, pkg.WorkspaceName)
}
cfg.Session.ApplyDmScope()
cfg.Session.DeriveDmScope()
return cfg, nil
}
@@ -1662,6 +1742,8 @@ func ResetToDefaults(configPath string) error {
return fmt.Errorf("backup before reset: %w", err)
}
cfg := DefaultConfig()
cfg.Session.ApplyDmScope()
cfg.Session.DeriveDmScope()
if err := cfg.SecurityCopyFrom(configPath); err != nil {
logger.WarnF("could not preserve security config", map[string]any{"error": err})
}
+193
View File
@@ -907,6 +907,29 @@ func TestDefaultConfig_WorkspacePath(t *testing.T) {
}
}
// TestDefaultConfig_AnthropicModelsUseClaudeAPIIDs verifies that first-party
// Anthropic defaults use Claude API model IDs, not dotted display names or
// Bedrock-style provider prefixes. See:
// https://platform.claude.com/docs/en/about-claude/models/model-ids-and-versions
func TestDefaultConfig_AnthropicModelsUseClaudeAPIIDs(t *testing.T) {
cfg := DefaultConfig()
checked := 0
for _, model := range cfg.ModelList {
if model.Provider != "anthropic" {
continue
}
checked++
if strings.Contains(model.Model, ".") {
t.Fatalf("Anthropic default model %q uses dotted ID %q", model.ModelName, model.Model)
}
}
if checked == 0 {
t.Fatal("DefaultConfig() missing Anthropic models")
}
}
// TestDefaultConfig_MaxTokens verifies max tokens has default value
func TestDefaultConfig_MaxTokens(t *testing.T) {
cfg := DefaultConfig()
@@ -1480,6 +1503,16 @@ func TestLoadConfig_LoadImageCanBeDisabled(t *testing.T) {
}
}
func TestDefaultConfig_MessageMediaDisabled(t *testing.T) {
cfg := DefaultConfig()
if !cfg.Tools.Message.Enabled {
t.Fatal("DefaultConfig().Tools.Message.Enabled should be true")
}
if cfg.Tools.Message.MediaEnabled {
t.Fatal("DefaultConfig().Tools.Message.MediaEnabled should be false")
}
}
func TestToolsConfig_GetFilterMinLength(t *testing.T) {
tests := []struct {
name string
@@ -1695,6 +1728,166 @@ func TestDefaultConfig_SessionDimensions(t *testing.T) {
}
}
func TestSessionConfig_ApplyDmScope(t *testing.T) {
tests := []struct {
name string
dmScope string
dimensions []string
want []string
}{
{
name: "per-channel-peer",
dmScope: "per-channel-peer",
want: []string{"chat", "sender"},
},
{
name: "per-channel",
dmScope: "per-channel",
want: []string{"chat"},
},
{
name: "per-peer",
dmScope: "per-peer",
want: []string{"sender"},
},
{
name: "global",
dmScope: "global",
want: nil,
},
{
name: "explicit dimensions take precedence",
dmScope: "per-channel-peer",
dimensions: []string{"sender"},
want: []string{"sender"},
},
{
name: "empty dm_scope is no-op",
dmScope: "",
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &SessionConfig{
DmScope: tt.dmScope,
Dimensions: tt.dimensions,
}
s.ApplyDmScope()
if len(s.Dimensions) != len(tt.want) {
t.Fatalf("Dimensions = %v, want %v", s.Dimensions, tt.want)
}
for i, v := range tt.want {
if s.Dimensions[i] != v {
t.Errorf("Dimensions[%d] = %q, want %q", i, s.Dimensions[i], v)
}
}
})
}
}
func TestSessionConfig_DeriveDmScope(t *testing.T) {
tests := []struct {
name string
dimensions []string
dmScope string
wantScope string
}{
{
name: "per-channel-peer from dimensions",
dimensions: []string{"chat", "sender"},
wantScope: "per-channel-peer",
},
{
name: "per-channel from dimensions",
dimensions: []string{"chat"},
wantScope: "per-channel",
},
{
name: "per-peer from dimensions",
dimensions: []string{"sender"},
wantScope: "per-peer",
},
{
name: "custom dimensions does not set scope",
dimensions: []string{"chat", "extra"},
wantScope: "",
},
{
name: "empty dimensions does not set scope",
dimensions: nil,
wantScope: "",
},
{
name: "existing dm_scope is not overwritten",
dimensions: []string{"chat", "sender"},
dmScope: "per-channel",
wantScope: "per-channel",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &SessionConfig{
DmScope: tt.dmScope,
Dimensions: tt.dimensions,
}
s.DeriveDmScope()
if s.DmScope != tt.wantScope {
t.Errorf("DmScope = %q, want %q", s.DmScope, tt.wantScope)
}
})
}
}
func TestSessionConfig_ApplyDmScope_ClearsStaleDimensions(t *testing.T) {
// Simulates the PATCH handler scenario: dm_scope changed but stale
// dimensions remain from the old scope. After clearing dimensions,
// ApplyDmScope should re-derive from the new dm_scope.
tests := []struct {
name string
dmScope string
want []string
}{
{
name: "per-channel-peer to per-channel",
dmScope: "per-channel",
want: []string{"chat"},
},
{
name: "per-channel-peer to per-peer",
dmScope: "per-peer",
want: []string{"sender"},
},
{
name: "per-channel-peer to global",
dmScope: "global",
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &SessionConfig{
DmScope: tt.dmScope,
Dimensions: []string{"chat", "sender"}, // stale from per-channel-peer
}
// Simulate what the PATCH handler does: clear dimensions when dm_scope changes
s.Dimensions = nil
s.ApplyDmScope()
if len(s.Dimensions) != len(tt.want) {
t.Fatalf("Dimensions = %v, want %v", s.Dimensions, tt.want)
}
for i, v := range tt.want {
if s.Dimensions[i] != v {
t.Errorf("Dimensions[%d] = %q, want %q", i, s.Dimensions[i], v)
}
}
})
}
}
func TestDefaultConfig_WorkspacePath_Default(t *testing.T) {
t.Setenv("PICOCLAW_HOME", "")
+11 -3
View File
@@ -88,7 +88,7 @@ func DefaultConfig() *Config {
{
ModelName: "claude-sonnet-4.6",
Provider: "anthropic",
Model: "claude-sonnet-4.6",
Model: "claude-sonnet-4-6",
APIBase: "https://api.anthropic.com/v1",
},
@@ -333,6 +333,11 @@ func DefaultConfig() *Config {
Enabled: false,
MaxResults: 5,
},
Kagi: KagiConfig{
Enabled: false,
BaseURL: "https://kagi.com/api/v1/search",
MaxResults: 5,
},
Sogou: SogouConfig{
Enabled: true,
MaxResults: 5,
@@ -447,8 +452,11 @@ func DefaultConfig() *Config {
LoadImage: ToolConfig{
Enabled: true,
},
Message: ToolConfig{
Enabled: true,
Message: MessageToolsConfig{
ToolConfig: ToolConfig{
Enabled: true,
},
MediaEnabled: false,
},
ReadFile: ReadFileToolConfig{
Enabled: true,
+10 -4
View File
@@ -51,7 +51,7 @@ channels:
token: "your-discord-bot-token"
# Web Tool Keys
# Brave, Tavily, Perplexity: Use 'api_keys' array
# Brave, Tavily, Perplexity, Kagi: Use 'api_keys' array
# GLMSearch, BaiduSearch: Use 'api_key' single string
web:
@@ -65,6 +65,9 @@ web:
perplexity:
api_keys:
- "pplx-your-perplexity-api-key" # Single key in array format
kagi:
api_keys:
- "your-kagi-api-key" # Single key in array format
glm_search:
api_key: "your-glm-search-api-key" # Single key (not array)
baidu_search:
@@ -239,7 +242,7 @@ channels:
## Web Tool API Keys
**Brave, Tavily, Perplexity:**
**Brave, Tavily, Perplexity, Kagi:**
```yaml
web:
@@ -253,6 +256,9 @@ web:
perplexity:
api_keys:
- "pplx-key"
kagi:
api_keys:
- "kagi-key"
```
Use `api_keys` (plural) array format.
@@ -443,7 +449,7 @@ web:
## Single Key Format
**Models, Brave, Tavily, Perplexity:**
**Models, Brave, Tavily, Perplexity, Kagi:**
```yaml
model_list:
@@ -565,7 +571,7 @@ and .security.yml values.
## Multiple API Keys Not Working
- Ensure you're using `api_keys` (plural) in .security.yml for models and web tools (except GLMSearch/BaiduSearch)
- Check that the array format is correct in YAML (proper indentation with dashes)
- Remember: Models, Brave, Tavily, Perplexity MUST use `api_keys` (array format)
- Remember: Models, Brave, Tavily, Perplexity, Kagi MUST use `api_keys` (array format)
- GLMSearch and BaiduSearch MUST use `api_key` (single string format)
## Keys Not Being Applied
+6 -1
View File
@@ -88,7 +88,12 @@ func ResolveGatewayLogLevel(path string) string {
data, err := os.ReadFile(path)
if err == nil {
_ = json.Unmarshal(data, &cfg)
if err := json.Unmarshal(data, &cfg); err != nil {
logger.WarnCF("config", "failed to parse gateway config, using defaults", map[string]any{
"path": path,
"error": err.Error(),
})
}
}
if envLevel := os.Getenv("PICOCLAW_LOG_LEVEL"); envLevel != "" {
+4 -1
View File
@@ -501,7 +501,10 @@ func mergeModelListsWithMap(mainML []any, secML map[string]any) error {
for i, m := range mainML {
if mVal, ok := m.(map[string]any); ok {
if name, hasName := mVal["model_name"]; hasName {
nameStr := name.(string)
nameStr, ok := name.(string)
if !ok {
return fmt.Errorf("model_name must be a string, got %T", name)
}
index := countMap[nameStr]
indexedKeys[fmt.Sprintf("%s:%d", nameStr, index)] = i
if _, ok := indexedKeys[nameStr]; !ok {
+13
View File
@@ -191,6 +191,10 @@ func TestAllSecurityKeysAccessible(t *testing.T) {
err = os.WriteFile(perplexityAPIKeyFile, []byte("pplx-perplexity-from-file-22222"), 0o600)
require.NoError(t, err)
kagiAPIKeyFile := filepath.Join(tmpDir, "kagi_api_key.txt")
err = os.WriteFile(kagiAPIKeyFile, []byte("kagi-from-file-33333"), 0o600)
require.NoError(t, err)
githubTokenFile := filepath.Join(tmpDir, "github_token.txt")
err = os.WriteFile(githubTokenFile, []byte("ghp-github-from-file-abc123"), 0o600)
require.NoError(t, err)
@@ -270,6 +274,9 @@ func TestAllSecurityKeysAccessible(t *testing.T) {
"perplexity": {
"enabled": true
},
"kagi": {
"enabled": true
},
"glm_search": {
"enabled": true
}
@@ -331,6 +338,9 @@ web:
perplexity:
api_keys:
- "file://perplexity_api_key.txt"
kagi:
api_keys:
- "file://kagi_api_key.txt"
glm_search:
api_key: "glm-test-glm-search-key"
@@ -456,6 +466,9 @@ skills:
assert.Equal(t, "pplx-perplexity-from-file-22222", cfg.Tools.Web.Perplexity.APIKey())
t.Logf("Perplexity APIKey(): %s", cfg.Tools.Web.Perplexity.APIKey())
assert.Equal(t, "kagi-from-file-33333", cfg.Tools.Web.Kagi.APIKey())
t.Logf("Kagi APIKey(): %s", cfg.Tools.Web.Kagi.APIKey())
// GLM Search - Note: GLM uses SetAPIKey (lowercase) internally
t.Logf("GLMSearch APIKey(): %s", cfg.Tools.Web.GLMSearch.APIKey.String())
assert.Equal(t, "glm-test-glm-search-key", cfg.Tools.Web.GLMSearch.APIKey.String())
+61 -2
View File
@@ -447,14 +447,37 @@ func (cs *CronService) AddJob(
return &job, nil
}
func (cs *CronService) GetJob(jobID string) (*CronJob, bool) {
cs.mu.RLock()
defer cs.mu.RUnlock()
for i := range cs.store.Jobs {
if cs.store.Jobs[i].ID == jobID {
jobCopy := cloneCronJob(cs.store.Jobs[i])
return &jobCopy, true
}
}
return nil, false
}
func (cs *CronService) UpdateJob(job *CronJob) error {
cs.mu.Lock()
defer cs.mu.Unlock()
for i := range cs.store.Jobs {
if cs.store.Jobs[i].ID == job.ID {
cs.store.Jobs[i] = *job
cs.store.Jobs[i].UpdatedAtMS = time.Now().UnixMilli()
previous := cs.store.Jobs[i]
updated := cloneCronJob(*job)
now := time.Now().UnixMilli()
updated.UpdatedAtMS = now
if updated.Enabled {
if previous.Enabled != updated.Enabled || !sameSchedule(previous.Schedule, updated.Schedule) {
updated.State.NextRunAtMS = cs.computeNextRun(&updated.Schedule, now)
}
} else {
updated.State.NextRunAtMS = nil
}
cs.store.Jobs[i] = updated
cs.notify()
@@ -464,6 +487,42 @@ func (cs *CronService) UpdateJob(job *CronJob) error {
return fmt.Errorf("job not found")
}
func cloneCronJob(job CronJob) CronJob {
clone := job
if job.Schedule.AtMS != nil {
atMS := *job.Schedule.AtMS
clone.Schedule.AtMS = &atMS
}
if job.Schedule.EveryMS != nil {
everyMS := *job.Schedule.EveryMS
clone.Schedule.EveryMS = &everyMS
}
if job.State.NextRunAtMS != nil {
nextRunAtMS := *job.State.NextRunAtMS
clone.State.NextRunAtMS = &nextRunAtMS
}
if job.State.LastRunAtMS != nil {
lastRunAtMS := *job.State.LastRunAtMS
clone.State.LastRunAtMS = &lastRunAtMS
}
return clone
}
func sameSchedule(a, b CronSchedule) bool {
return a.Kind == b.Kind &&
sameInt64(a.AtMS, b.AtMS) &&
sameInt64(a.EveryMS, b.EveryMS) &&
a.Expr == b.Expr &&
a.TZ == b.TZ
}
func sameInt64(a, b *int64) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
func (cs *CronService) RemoveJob(jobID string) bool {
cs.mu.Lock()
defer cs.mu.Unlock()
+130
View File
@@ -82,6 +82,136 @@ func TestCronService_CRUD(t *testing.T) {
}
}
func TestCronService_GetJobReturnsCopy(t *testing.T) {
cs, path := setupService(nil)
defer os.Remove(path)
everyMS := int64(60_000)
job, err := cs.AddJob("Task1", CronSchedule{Kind: "every", EveryMS: &everyMS}, "msg", "ch", "to")
if err != nil {
t.Fatalf("AddJob failed: %v", err)
}
if job.State.NextRunAtMS == nil {
t.Fatal("expected initial next run")
}
nextRun := *job.State.NextRunAtMS
got, ok := cs.GetJob(job.ID)
if !ok {
t.Fatal("GetJob should find job")
}
got.Name = "mutated"
got.Payload.Message = "changed"
if got.Schedule.EveryMS != nil {
*got.Schedule.EveryMS = 120_000
}
if got.State.NextRunAtMS != nil {
*got.State.NextRunAtMS = time.Now().Add(3 * time.Hour).UnixMilli()
}
again, ok := cs.GetJob(job.ID)
if !ok {
t.Fatal("GetJob should still find job")
}
if again.Name != "Task1" || again.Payload.Message != "msg" {
t.Fatalf("GetJob should return a copy, got %+v", again)
}
if again.Schedule.EveryMS == nil || *again.Schedule.EveryMS != everyMS {
t.Fatalf("GetJob should not alias schedule pointers, got %+v", again.Schedule)
}
if again.State.NextRunAtMS == nil || *again.State.NextRunAtMS != nextRun {
t.Fatalf("GetJob should not alias state pointers, got %+v", again.State)
}
}
func TestCronService_UpdateJobRecomputesNextRunOnScheduleOrEnabledChange(t *testing.T) {
cs, path := setupService(nil)
defer os.Remove(path)
at := time.Now().Add(time.Hour).UnixMilli()
job, err := cs.AddJob("Task1", CronSchedule{Kind: "at", AtMS: &at}, "msg", "ch", "to")
if err != nil {
t.Fatalf("AddJob failed: %v", err)
}
if job.State.NextRunAtMS == nil {
t.Fatal("expected initial next run")
}
initialNextRun := *job.State.NextRunAtMS
everyMS := int64(30_000)
job.Schedule = CronSchedule{Kind: "every", EveryMS: &everyMS}
if err := cs.UpdateJob(job); err != nil {
t.Fatalf("UpdateJob schedule failed: %v", err)
}
updated, ok := cs.GetJob(job.ID)
if !ok {
t.Fatal("updated job not found")
}
if updated.State.NextRunAtMS == nil {
t.Fatal("expected recomputed next run after schedule change")
}
if *updated.State.NextRunAtMS == initialNextRun {
t.Fatalf("next run should be recomputed, still %d", initialNextRun)
}
if disabled := cs.EnableJob(job.ID, false); disabled == nil {
t.Fatal("EnableJob(false) returned nil")
}
disabled, ok := cs.GetJob(job.ID)
if !ok {
t.Fatal("disabled job not found")
}
disabled.Enabled = true
if err := cs.UpdateJob(disabled); err != nil {
t.Fatalf("UpdateJob enabled failed: %v", err)
}
reenabled, ok := cs.GetJob(job.ID)
if !ok {
t.Fatal("reenabled job not found")
}
if !reenabled.Enabled || reenabled.State.NextRunAtMS == nil {
t.Fatalf("expected enabled job with next run, got %+v", reenabled)
}
}
func TestCronService_UpdateJobPreservesRunStateOnPayloadOnlyChange(t *testing.T) {
cs, path := setupService(nil)
defer os.Remove(path)
everyMS := int64(60_000)
job, err := cs.AddJob("Task1", CronSchedule{Kind: "every", EveryMS: &everyMS}, "msg", "ch", "to")
if err != nil {
t.Fatalf("AddJob failed: %v", err)
}
lastRun := time.Now().Add(-time.Minute).UnixMilli()
job.State.LastRunAtMS = &lastRun
job.State.LastStatus = "ok"
job.State.LastError = "previous"
if job.State.NextRunAtMS == nil {
t.Fatal("expected next run before update")
}
nextRun := *job.State.NextRunAtMS
job.Payload.Message = "updated msg"
if err := cs.UpdateJob(job); err != nil {
t.Fatalf("UpdateJob failed: %v", err)
}
updated, ok := cs.GetJob(job.ID)
if !ok {
t.Fatal("updated job not found")
}
if updated.State.LastRunAtMS == nil || *updated.State.LastRunAtMS != lastRun {
t.Fatalf("last run changed: %+v", updated.State)
}
if updated.State.LastStatus != "ok" || updated.State.LastError != "previous" {
t.Fatalf("last status changed: %+v", updated.State)
}
if updated.State.NextRunAtMS == nil || *updated.State.NextRunAtMS != nextRun {
t.Fatalf("next run should be preserved: before=%d after=%+v", nextRun, updated.State.NextRunAtMS)
}
}
// 2. Test Cron Expression Calculation Logic
func TestCronService_ComputeNextRun(t *testing.T) {
cs, path := setupService(nil)
+4 -1
View File
@@ -67,7 +67,10 @@ type DefaultDraftGenerator struct {
func NewDefaultDraftGenerator(workspace string) *DefaultDraftGenerator {
builtinSkillsDir := strings.TrimSpace(os.Getenv(config.EnvBuiltinSkills))
if builtinSkillsDir == "" {
wd, _ := os.Getwd()
wd, err := os.Getwd()
if err != nil {
wd = config.GetHome()
}
builtinSkillsDir = filepath.Join(wd, "skills")
}
+4 -1
View File
@@ -18,7 +18,10 @@ type SkillsRecaller struct {
func NewSkillsRecaller(workspace string) *SkillsRecaller {
builtinSkillsDir := strings.TrimSpace(os.Getenv(config.EnvBuiltinSkills))
if builtinSkillsDir == "" {
wd, _ := os.Getwd()
wd, err := os.Getwd()
if err != nil {
wd = config.GetHome()
}
builtinSkillsDir = filepath.Join(wd, "skills")
}
+5
View File
@@ -222,6 +222,11 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr
if err != nil {
return err
}
// All services (channels + shared HTTP server) are up; mark the health
// server ready so GET /ready reports "ready". The health endpoints are
// mounted on the shared gateway mux, so Health.Server.Start() (which would
// otherwise set this) is never called — we flip the flag explicitly here.
runningServices.HealthServer.SetReady(true)
publishGatewayEvent(agentLoop, runtimeevents.KindGatewayReady, startedAt, nil)
closeListeners = false
+4 -1
View File
@@ -62,7 +62,10 @@ func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig,
if !isolation.Enabled || cmd == nil || cmd.Process == nil {
return nil
}
resourcesAny, _ := windowsPendingResources.LoadAndDelete(cmd)
resourcesAny, loaded := windowsPendingResources.LoadAndDelete(cmd)
if !loaded {
return nil
}
resources, _ := resourcesAny.(windowsProcessResources)
// Job objects can only be attached after the process exists, so the Windows
// backend finishes isolation in this post-start hook.
+2 -2
View File
@@ -62,7 +62,7 @@ func (s *isolatedPipeRWC) Write(p []byte) (n int, err error) {
func (s *isolatedPipeRWC) Close() error {
if err := s.stdin.Close(); err != nil {
return fmt.Errorf("closing stdin: %v", err)
return fmt.Errorf("closing stdin: %w", err)
}
resChan := make(chan error, 1)
go func() {
@@ -205,7 +205,7 @@ func (c *isolatedIOConn) Write(ctx context.Context, msg jsonrpc.Message) error {
defer c.writeMu.Unlock()
data, err := jsonrpc.EncodeMessage(msg)
if err != nil {
return fmt.Errorf("marshaling message: %v", err)
return fmt.Errorf("marshaling message: %w", err)
}
data = append(data, '\n')
_, err = c.rwc.Write(data)
+50 -1
View File
@@ -4,6 +4,8 @@ import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"hash/fnv"
@@ -227,6 +229,11 @@ func (s *JSONLStore) UpsertSessionMeta(
// PromoteAliasHistory atomically promotes the first non-empty alias session
// into the canonical session when the canonical session is still empty.
//
// Main-session aliases (e.g. "agent:main:main" or its opaque form) are
// skipped during promotion. The main session is a shared global fallback
// and promoting its history into individual sessions would attach stale
// messages to every new Web UI session (issue #2972).
func (s *JSONLStore) PromoteAliasHistory(
_ context.Context,
sessionKey string,
@@ -240,6 +247,9 @@ func (s *JSONLStore) PromoteAliasHistory(
aliases = normalizeAliases(sessionKey, aliases)
for _, alias := range aliases {
if isMainSessionAlias(alias) {
continue
}
unlock := s.lockSessionPair(sessionKey, alias)
promoted, err := s.promoteAliasHistoryLocked(sessionKey, alias, scope, aliases)
unlock()
@@ -251,6 +261,34 @@ func (s *JSONLStore) PromoteAliasHistory(
return false, nil
}
// isMainSessionAlias reports whether alias is the legacy or opaque main-session
// key. The main session ("agent:<agent>:main") is a shared fallback and should
// not have its history promoted into individual per-channel sessions.
func isMainSessionAlias(alias string) bool {
if alias == "" {
return false
}
// Legacy form: "agent:main:main" (exactly 3 colon-separated parts)
// Must not match "agent:sales:direct:main" etc.
if strings.HasPrefix(alias, "agent:") && strings.HasSuffix(alias, ":main") {
parts := strings.SplitN(alias, ":", 4)
if len(parts) == 3 {
return true
}
}
// Opaque form: "sk_v1_" + SHA256("agent:main:main")
if strings.HasPrefix(alias, "sk_v1_") {
for _, agentID := range []string{"main", "Main", "MAIN"} {
legacy := "agent:" + agentID + ":main"
hash := sha256.Sum256([]byte(legacy))
if "sk_v1_"+hex.EncodeToString(hash[:]) == alias {
return true
}
}
}
return false
}
// ResolveSessionKey returns the canonical session key for a candidate key.
// It short-circuits direct canonical keys when possible, then scans metadata
// once to resolve aliases or canonical metadata keys.
@@ -561,6 +599,12 @@ func (s *JSONLStore) addMsg(sessionKey string, msg providers.Message) error {
l.Lock()
defer l.Unlock()
now := time.Now()
if msg.CreatedAt == nil {
msg.CreatedAt = &now
}
// Append the message as a single JSON line.
line, err := json.Marshal(msg)
if err != nil {
@@ -598,7 +642,6 @@ func (s *JSONLStore) addMsg(sessionKey string, msg providers.Message) error {
if err != nil {
return err
}
now := time.Now()
if meta.Count == 0 && meta.CreatedAt.IsZero() {
meta.CreatedAt = now
}
@@ -726,6 +769,12 @@ func (s *JSONLStore) SetHistory(
meta.Count = len(history)
meta.UpdatedAt = now
for i := range history {
if history[i].CreatedAt == nil {
history[i].CreatedAt = &now
}
}
// Write meta BEFORE rewriting the JSONL file. If we crash between
// the two writes, meta has Skip=0 and the old file is still intact,
// so GetHistory reads from line 1 — returning "too many" messages
+137
View File
@@ -1058,6 +1058,143 @@ func TestMultipleSessions_Isolation(t *testing.T) {
}
}
func TestStore_SetsCreatedAtWhenNil(t *testing.T) {
type writeOp struct {
name string
fn func(store *JSONLStore, key string) (expectedCount int)
}
ops := []writeOp{
{
name: "AddMessage",
fn: func(store *JSONLStore, key string) int {
if err := store.AddMessage(context.Background(), key, "user", "hello"); err != nil {
t.Fatalf("AddMessage: %v", err)
}
return 1
},
},
{
name: "AddFullMessage",
fn: func(store *JSONLStore, key string) int {
if err := store.AddFullMessage(context.Background(), key, providers.Message{
Role: "user",
Content: "hello from full",
}); err != nil {
t.Fatalf("AddFullMessage: %v", err)
}
return 1
},
},
{
name: "SetHistory",
fn: func(store *JSONLStore, key string) int {
if err := store.SetHistory(context.Background(), key, []providers.Message{
{Role: "user", Content: "msg1"},
{Role: "assistant", Content: "msg2"},
}); err != nil {
t.Fatalf("SetHistory: %v", err)
}
return 2
},
},
}
for _, op := range ops {
t.Run(op.name, func(t *testing.T) {
store := newTestStore(t)
key := "s1"
before := time.Now().Add(-time.Second)
expectedCount := op.fn(store, key)
after := time.Now().Add(time.Second)
history, err := store.GetHistory(context.Background(), key)
if err != nil {
t.Fatalf("GetHistory: %v", err)
}
if len(history) != expectedCount {
t.Fatalf("expected %d messages, got %d", expectedCount, len(history))
}
for i := range history {
if history[i].CreatedAt == nil || history[i].CreatedAt.IsZero() {
t.Errorf("message %d CreatedAt is zero — not set by %s", i, op.name)
}
if history[i].CreatedAt.Before(before) || history[i].CreatedAt.After(after) {
t.Errorf(
"message %d CreatedAt %v outside expected window [%v, %v]",
i, history[i].CreatedAt, before, after,
)
}
}
})
}
}
func TestStore_PreservesExistingCreatedAt(t *testing.T) {
t1 := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)
t2 := time.Date(2026, 1, 1, 11, 0, 0, 0, time.UTC)
type writeOp struct {
name string
fn func(store *JSONLStore, key string)
wantTimes []time.Time
}
ops := []writeOp{
{
name: "AddFullMessage",
fn: func(store *JSONLStore, key string) {
if err := store.AddFullMessage(context.Background(), key, providers.Message{
Role: "user",
Content: "custom time",
CreatedAt: &t1,
}); err != nil {
t.Fatalf("AddFullMessage: %v", err)
}
},
wantTimes: []time.Time{t1},
},
{
name: "SetHistory",
fn: func(store *JSONLStore, key string) {
if err := store.SetHistory(context.Background(), key, []providers.Message{
{Role: "user", Content: "msg1", CreatedAt: &t1},
{Role: "assistant", Content: "msg2", CreatedAt: &t2},
}); err != nil {
t.Fatalf("SetHistory: %v", err)
}
},
wantTimes: []time.Time{t1, t2},
},
}
for _, op := range ops {
t.Run(op.name, func(t *testing.T) {
store := newTestStore(t)
key := "s1"
op.fn(store, key)
history, err := store.GetHistory(context.Background(), key)
if err != nil {
t.Fatalf("GetHistory: %v", err)
}
if len(history) != len(op.wantTimes) {
t.Fatalf("expected %d messages, got %d", len(op.wantTimes), len(history))
}
for i, want := range op.wantTimes {
if history[i].CreatedAt == nil || !history[i].CreatedAt.Equal(want) {
t.Errorf(
"message %d CreatedAt = %v, want %v (should preserve caller-provided time)",
i, history[i].CreatedAt, want,
)
}
}
})
}
}
func BenchmarkAddMessage(b *testing.B) {
dir := b.TempDir()
store, err := NewJSONLStore(dir)
+6 -3
View File
@@ -1,6 +1,7 @@
package internal
import (
"fmt"
"io"
"os"
"path/filepath"
@@ -149,8 +150,10 @@ func CopyFile(src, dst string) error {
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
_, copyErr := io.Copy(dstFile, srcFile)
if closeErr := dstFile.Close(); closeErr != nil && copyErr == nil {
return fmt.Errorf("close destination file %s: %w", dst, closeErr)
}
return copyErr
}
+8 -1
View File
@@ -64,7 +64,14 @@ func WritePidFile(homePath, host string, port int) (*PidFileData, error) {
// pass the isProcessRunning check, blocking new gateway starts.
// Treat recorded PID 1 as always stale.
if data.PID != 1 && isProcessRunning(data.PID) {
return nil, fmt.Errorf("gateway is already running (PID: %d, version: %s)", data.PID, data.Version)
// Verify the process is actually a picoclaw instance.
// If the PID was reused by an unrelated process
// (e.g. systemd-resolved after a kill -9), treat
// the PID file as stale and proceed with startup.
if isPicoclawProcess(data.PID) {
return nil, fmt.Errorf("gateway is already running (PID: %d, version: %s)", data.PID, data.Version)
}
logger.Warnf("found pid file (PID: %d) but process is not picoclaw", data.PID)
}
logger.Warnf("not running (PID: %d) so will remove the pid file: %s", data.PID, pidPath)
}
+16 -1
View File
@@ -4,7 +4,9 @@ package pid
import (
"errors"
"fmt"
"os"
"strings"
"syscall"
)
@@ -18,7 +20,7 @@ func isProcessRunning(pid int) bool {
if err != nil {
return false
}
// Signal(nil) does not kill the process but checks existence on Unix.
// Signal(0) does not kill the process but checks existence on Unix.
err = p.Signal(syscall.Signal(0))
if err == nil {
return true
@@ -27,3 +29,16 @@ func isProcessRunning(pid int) bool {
// EPERM means the process exists but we are not allowed to signal it.
return errors.As(err, &errno) && errno == syscall.EPERM
}
// isPicoclawProcess reads /proc/<pid>/comm to confirm the process name
// contains "picoclaw". Returns false when the comm file can be read and
// the name does not match (e.g., PID was reused by an unrelated process).
// Returns true if /proc/<pid>/comm is unreadable so the call site falls
// back to trusting the liveness check alone.
func isPicoclawProcess(pid int) bool {
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid))
if err != nil {
return true // cannot verify — trust liveness check
}
return strings.Contains(strings.TrimSpace(string(data)), "picoclaw")
}
+32
View File
@@ -3,6 +3,7 @@
package pid
import (
"strings"
"syscall"
"unsafe"
)
@@ -12,6 +13,7 @@ var (
procOpenProcess = kernel32.NewProc("OpenProcess")
procGetExitCodeProcess = kernel32.NewProc("GetExitCodeProcess")
procCloseHandle = kernel32.NewProc("CloseHandle")
procQueryFullProcessImageNameW = kernel32.NewProc("QueryFullProcessImageNameW")
processQueryLimitedInformation = uint32(0x1000)
stillActive = uint32(259)
)
@@ -40,3 +42,33 @@ func isProcessRunning(pid int) bool {
}
return exitCode == stillActive
}
// isPicoclawProcess uses QueryFullProcessImageNameW to confirm the
// process image name contains "picoclaw". Returns false when the name
// clearly does not match. Returns true if the query fails, falling
// back to trusting the liveness check alone.
func isPicoclawProcess(pid int) bool {
handle, _, _ := procOpenProcess.Call(
uintptr(processQueryLimitedInformation),
0,
uintptr(pid),
)
if handle == 0 {
return true // cannot open — trust liveness check
}
defer procCloseHandle.Call(handle)
var buf [260]uint16
var size uint32 = 260
ret, _, _ := procQueryFullProcessImageNameW.Call(
uintptr(handle),
0, // WIN32_NAME_FORMAT
uintptr(unsafe.Pointer(&buf[0])),
uintptr(unsafe.Pointer(&size)),
)
if ret == 0 {
return true // cannot verify — trust liveness check
}
name := strings.ToLower(syscall.UTF16ToString(buf[:size]))
return strings.Contains(name, "picoclaw")
}
+4 -2
View File
@@ -219,7 +219,7 @@ func buildParams(
apiModel := strings.ReplaceAll(model, ".", "-")
params := anthropic.MessageNewParams{
Model: anthropic.Model(apiModel),
Model: apiModel,
Messages: anthropicMessages,
MaxTokens: maxTokens,
}
@@ -262,7 +262,9 @@ func applyThinkingConfig(params *anthropic.MessageNewParams, level string) {
params.Temperature = anthropic.MessageNewParams{}.Temperature
if level == "adaptive" {
adaptive := anthropic.NewThinkingConfigAdaptiveParam()
adaptive := anthropic.ThinkingConfigAdaptiveParam{
Display: anthropic.ThinkingConfigAdaptiveDisplaySummarized,
}
params.Thinking = anthropic.ThinkingConfigParamUnion{OfAdaptive: &adaptive}
params.OutputConfig = anthropic.OutputConfigParam{
Effort: anthropic.OutputConfigEffortHigh,
+1 -1
View File
@@ -21,7 +21,7 @@ func TestBuildParams_BasicMessage(t *testing.T) {
if err != nil {
t.Fatalf("buildParams() error: %v", err)
}
if string(params.Model) != "claude-sonnet-4-6" {
if params.Model != "claude-sonnet-4-6" {
t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4-6")
}
if params.MaxTokens != 1024 {
+56
View File
@@ -0,0 +1,56 @@
//go:build azidentity
// Entra ID (DefaultAzureCredential) auth adapter.
// Built only when -tags azidentity is supplied; otherwise identity_stub.go
// satisfies the same exported API with a friendly error.
package azure
import (
"context"
"fmt"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
)
// azureOpenAIScope is the OAuth scope for Azure OpenAI (Cognitive Services).
// Service-wide scope, so it covers all regions including sovereign clouds.
const azureOpenAIScope = "https://cognitiveservices.azure.com/.default"
// NewProviderWithIdentity creates an Azure OpenAI provider authenticated via
// the DefaultAzureCredential chain (env vars, workload identity, managed
// identity, Azure CLI, ...). Construction itself only fails if the credential
// chain cannot be built; misconfigured environments surface their error on
// the first Chat call when GetToken is invoked.
func NewProviderWithIdentity(apiBase, proxy, userAgent string, opts ...Option) (*Provider, error) {
cred, err := azidentity.NewDefaultAzureCredential(nil)
if err != nil {
return nil, fmt.Errorf("creating azure default credential: %w", err)
}
ts := func(ctx context.Context) (string, error) {
tok, err := cred.GetToken(ctx, policy.TokenRequestOptions{
Scopes: []string{azureOpenAIScope},
})
if err != nil {
return "", fmt.Errorf("acquiring azure access token: %w", err)
}
return tok.Token, nil
}
return NewProviderWithTokenSource(apiBase, proxy, userAgent, ts, opts...), nil
}
// NewProviderWithIdentityAndTimeout mirrors NewProviderWithTimeout for the
// identity auth path.
func NewProviderWithIdentityAndTimeout(
apiBase, proxy, userAgent string,
requestTimeoutSeconds int,
) (*Provider, error) {
return NewProviderWithIdentity(
apiBase, proxy, userAgent,
WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
)
}
+24
View File
@@ -0,0 +1,24 @@
//go:build !azidentity
// Stub for the Entra ID auth path when built without the azidentity tag.
// Mirrors the exported surface of identity.go so callers compile cleanly
// in the default build.
package azure
import "fmt"
const azidentityBuildHint = "azure identity auth not available: build with -tags azidentity to enable Entra ID auth, or set api_key"
// NewProviderWithIdentity returns an error in the default build.
func NewProviderWithIdentity(apiBase, proxy, userAgent string, opts ...Option) (*Provider, error) {
return nil, fmt.Errorf("%s", azidentityBuildHint)
}
// NewProviderWithIdentityAndTimeout returns an error in the default build.
func NewProviderWithIdentityAndTimeout(
apiBase, proxy, userAgent string,
requestTimeoutSeconds int,
) (*Provider, error) {
return nil, fmt.Errorf("%s", azidentityBuildHint)
}
+39
View File
@@ -0,0 +1,39 @@
//go:build azidentity
package azure
import (
"testing"
)
func TestNewProviderWithIdentity_Construction(t *testing.T) {
// DefaultAzureCredential construction itself does not require any env vars;
// failures surface only on the first GetToken call. Verify we get a
// non-nil provider back with a token source wired in.
p, err := NewProviderWithIdentity("https://example.openai.azure.com", "", "ua-test")
if err != nil {
t.Fatalf("NewProviderWithIdentity() error = %v", err)
}
if p == nil {
t.Fatal("NewProviderWithIdentity() returned nil provider")
}
if p.tokenSource == nil {
t.Fatal("provider.tokenSource should be set")
}
if p.apiKey != "" {
t.Errorf("provider.apiKey = %q, want empty", p.apiKey)
}
}
func TestNewProviderWithIdentityAndTimeout_Construction(t *testing.T) {
p, err := NewProviderWithIdentityAndTimeout("https://example.openai.azure.com", "", "ua-test", 30)
if err != nil {
t.Fatalf("NewProviderWithIdentityAndTimeout() error = %v", err)
}
if p == nil {
t.Fatal("returned nil provider")
}
if p.httpClient.Timeout.Seconds() != 30 {
t.Errorf("timeout = %v, want 30s", p.httpClient.Timeout)
}
}
+45 -5
View File
@@ -33,10 +33,11 @@ const (
// It handles Azure-specific authentication (Bearer token), URL construction
// (Responses API), and request/response formatting.
type Provider struct {
apiKey string
apiBase string
httpClient *http.Client
userAgent string
apiKey string
apiBase string
httpClient *http.Client
userAgent string
tokenSource func(ctx context.Context) (string, error)
}
// Option configures the Azure Provider.
@@ -58,6 +59,14 @@ func WithUserAgent(userAgent string) Option {
}
}
// WithTokenSource sets a callback that returns a bearer token per request.
// When set, it takes precedence over the static api key.
func WithTokenSource(ts func(ctx context.Context) (string, error)) Option {
return func(p *Provider) {
p.tokenSource = ts
}
}
// NewProvider creates a new Azure OpenAI provider.
func NewProvider(apiKey, apiBase, proxy, userAgent string, opts ...Option) *Provider {
p := &Provider{
@@ -84,6 +93,30 @@ func NewProviderWithTimeout(apiKey, apiBase, proxy, userAgent string, requestTim
)
}
// NewProviderWithTokenSource creates a new Azure OpenAI provider that obtains its
// bearer token from the supplied callback on every request. Used for Entra ID auth
// where tokens are short-lived and refreshed by the underlying credential.
func NewProviderWithTokenSource(
apiBase, proxy, userAgent string,
tokenSource func(ctx context.Context) (string, error),
opts ...Option,
) *Provider {
p := &Provider{
apiBase: strings.TrimRight(apiBase, "/"),
userAgent: userAgent,
httpClient: common.NewHTTPClient(proxy),
tokenSource: tokenSource,
}
for _, opt := range opts {
if opt != nil {
opt(p)
}
}
return p
}
// Chat sends a request to the Azure OpenAI Responses API endpoint.
// The model parameter is passed in the request body.
func (p *Provider) Chat(
@@ -147,7 +180,14 @@ func (p *Provider) Chat(
}
req.Header.Set("Content-Type", "application/json")
if p.apiKey != "" {
switch {
case p.tokenSource != nil:
tok, tokErr := p.tokenSource(ctx)
if tokErr != nil {
return nil, fmt.Errorf("acquiring azure identity token: %w", tokErr)
}
req.Header.Set("Authorization", "Bearer "+tok)
case p.apiKey != "":
req.Header.Set("Authorization", "Bearer "+p.apiKey)
}
if p.userAgent != "" {
+67
View File
@@ -1,7 +1,9 @@
package azure
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -415,3 +417,68 @@ func TestProviderChat_AzureNoNativeWebSearch(t *testing.T) {
t.Errorf("tool type = %v, want %q", tool["type"], "function")
}
}
func TestProviderChat_AzureTokenSourceHeader(t *testing.T) {
var capturedAuth string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedAuth = r.Header.Get("Authorization")
writeValidResponse(w)
}))
defer server.Close()
ts := func(ctx context.Context) (string, error) {
return "fake-entra-token", nil
}
p := NewProviderWithTokenSource(server.URL, "", "", ts)
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if capturedAuth != "Bearer fake-entra-token" {
t.Errorf("Authorization header = %q, want %q", capturedAuth, "Bearer fake-entra-token")
}
}
func TestProviderChat_AzureTokenSourceError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeValidResponse(w)
}))
defer server.Close()
wantErr := errors.New("creds gone")
ts := func(ctx context.Context) (string, error) {
return "", wantErr
}
p := NewProviderWithTokenSource(server.URL, "", "", ts)
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
if err == nil {
t.Fatal("expected error from token source")
}
if !strings.Contains(err.Error(), "creds gone") {
t.Errorf("error %q should wrap original token source error", err.Error())
}
}
func TestProviderChat_AzureTokenSourcePrecedence(t *testing.T) {
var capturedAuth string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedAuth = r.Header.Get("Authorization")
writeValidResponse(w)
}))
defer server.Close()
ts := func(ctx context.Context) (string, error) {
return "from-token-source", nil
}
// Provider with both an api_key AND a token source: token source must win.
p := NewProvider("static-api-key", server.URL, "", "", WithTokenSource(ts))
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if capturedAuth != "Bearer from-token-source" {
t.Errorf("Authorization header = %q, want token-source value", capturedAuth)
}
}
+25 -6
View File
@@ -143,7 +143,22 @@ type converseParams struct {
toolConfig *types.ToolConfiguration
}
func buildConverseParams(messages []Message, tools []ToolDefinition, options map[string]any) converseParams {
// modelDeprecatesTemperature reports whether the given Bedrock model rejects the
// temperature inference parameter. Newer Claude models (Opus 4.8 and later) treat
// temperature as deprecated and return a ValidationException if it is supplied:
//
// ValidationException: The model returned the following errors:
// temperature is deprecated for this model.
//
// The match is intentionally loose: Bedrock model IDs and inference-profile ARNs
// embed the model name (e.g. "us.anthropic.claude-opus-4-8-20260514-v1:0"), so a
// substring check covers both bare IDs and region-prefixed inference profiles.
func modelDeprecatesTemperature(model string) bool {
m := strings.ToLower(model)
return strings.Contains(m, "claude-opus-4-8")
}
func buildConverseParams(messages []Message, tools []ToolDefinition, model string, options map[string]any) converseParams {
bedrockMessages, systemPrompts := convertMessages(messages)
var inferenceConfig *types.InferenceConfiguration
@@ -159,10 +174,14 @@ func buildConverseParams(messages []Message, tools []ToolDefinition, options map
}
if temp, ok := common.AsFloat(options["temperature"]); ok {
if inferenceConfig == nil {
inferenceConfig = &types.InferenceConfiguration{}
if modelDeprecatesTemperature(model) {
log.Printf("bedrock: temperature dropped because model %q no longer supports it", model)
} else {
if inferenceConfig == nil {
inferenceConfig = &types.InferenceConfiguration{}
}
inferenceConfig.Temperature = aws.Float32(float32(temp))
}
inferenceConfig.Temperature = aws.Float32(float32(temp))
}
var toolConfig *types.ToolConfiguration
@@ -199,7 +218,7 @@ func (p *Provider) Chat(
defer cancel()
}
params := buildConverseParams(messages, tools, options)
params := buildConverseParams(messages, tools, model, options)
input := &bedrockruntime.ConverseInput{
ModelId: aws.String(model),
Messages: params.messages,
@@ -242,7 +261,7 @@ func (p *Provider) ChatStream(
}
}
params := buildConverseParams(messages, tools, options)
params := buildConverseParams(messages, tools, model, options)
input := &bedrockruntime.ConverseStreamInput{
ModelId: aws.String(model),
Messages: params.messages,
@@ -875,3 +875,49 @@ func TestParseStreamResponse_StopReasons(t *testing.T) {
})
}
}
func TestModelDeprecatesTemperature(t *testing.T) {
tests := []struct {
name string
model string
want bool
}{
{"opus 4.8 bare id", "claude-opus-4-8-20260514-v1:0", true},
{"opus 4.8 inference profile", "us.anthropic.claude-opus-4-8-20260514-v1:0", true},
{"opus 4.8 mixed case", "US.Anthropic.Claude-Opus-4-8", true},
{"opus 4.7 unaffected", "us.anthropic.claude-opus-4-7-20250101-v1:0", false},
{"sonnet unaffected", "us.anthropic.claude-sonnet-4-6", false},
{"empty", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, modelDeprecatesTemperature(tt.model))
})
}
}
func TestBuildConverseParams_DropsTemperatureForDeprecatedModel(t *testing.T) {
options := map[string]any{
"temperature": 0.7,
"max_tokens": 1024,
}
params := buildConverseParams(nil, nil, "us.anthropic.claude-opus-4-8-20260514-v1:0", options)
require.NotNil(t, params.inferenceConfig, "max_tokens should still populate inference config")
assert.Nil(t, params.inferenceConfig.Temperature, "temperature must be omitted for opus 4.8")
require.NotNil(t, params.inferenceConfig.MaxTokens)
assert.Equal(t, int32(1024), *params.inferenceConfig.MaxTokens)
}
func TestBuildConverseParams_KeepsTemperatureForSupportedModel(t *testing.T) {
options := map[string]any{
"temperature": 0.7,
}
params := buildConverseParams(nil, nil, "us.anthropic.claude-opus-4-7-20250101-v1:0", options)
require.NotNil(t, params.inferenceConfig)
require.NotNil(t, params.inferenceConfig.Temperature)
assert.InDelta(t, 0.7, float64(*params.inferenceConfig.Temperature), 0.0001)
}
+4
View File
@@ -111,6 +111,10 @@ var (
substr("tool_use_id"),
substr("messages.1.content.1.tool_use.id"),
substr("invalid request format"),
// Zhipu API error code 1210: parameter error (e.g., image format incompatible)
substr("error code: 1210"),
substr("error code 1210"),
substr("zhipu api error code: 1210"),
}
contextOverflowPatterns = []errorPattern{
rxp(`context[_ ]?length[_ ]?exceeded`),
+17 -8
View File
@@ -137,23 +137,32 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
return finalizeProviderFromConfig(provider, modelID, cfg)
case "azure":
// Azure OpenAI uses deployment-based URLs, api-key header auth,
// and always sends max_completion_tokens.
if cfg.APIKey() == "" {
return nil, "", fmt.Errorf("api_key is required for azure protocol")
}
// Azure OpenAI uses deployment-based URLs. Auth is Bearer token via api_key
// when set; otherwise falls back to Entra ID (DefaultAzureCredential).
if cfg.APIBase == "" {
return nil, "", fmt.Errorf(
"api_base is required for azure protocol (e.g., https://your-resource.openai.azure.com)",
)
}
return finalizeProviderFromConfig(azure.NewProviderWithTimeout(
cfg.APIKey(),
if cfg.APIKey() != "" {
return finalizeProviderFromConfig(azure.NewProviderWithTimeout(
cfg.APIKey(),
cfg.APIBase,
cfg.Proxy,
userAgent,
cfg.RequestTimeout,
), modelID, cfg)
}
provider, err := azure.NewProviderWithIdentityAndTimeout(
cfg.APIBase,
cfg.Proxy,
userAgent,
cfg.RequestTimeout,
), modelID, cfg)
)
if err != nil {
return nil, "", err
}
return finalizeProviderFromConfig(provider, modelID, cfg)
case "bedrock":
// AWS Bedrock uses AWS SDK credentials (env vars, profiles, IAM roles, etc.)
@@ -0,0 +1,36 @@
//go:build azidentity
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package providers
import (
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
// With the azidentity build tag, an azure config with no api_key must succeed
// (falls back to DefaultAzureCredential). Construction does not require any
// real Azure environment — token acquisition happens on first Chat.
func TestCreateProviderFromConfig_AzureIdentityFallback(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "azure-gpt5",
Model: "azure/my-gpt5-deployment",
APIBase: "https://my-resource.openai.azure.com",
}
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 != "my-gpt5-deployment" {
t.Errorf("modelID = %q, want %q", modelID, "my-gpt5-deployment")
}
}
+44 -2
View File
@@ -171,6 +171,30 @@ func TestCreateProviderFromConfig_UsesExplicitProvider(t *testing.T) {
}
}
func TestCreateProviderFromConfig_DeepSeekSupportsThinking(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "deepseek-v4-flash",
Provider: "deepseek",
Model: "deepseek-v4-flash",
}
cfg.SetAPIKey("test-key")
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
t.Fatalf("CreateProviderFromConfig() error = %v", err)
}
if modelID != "deepseek-v4-flash" {
t.Fatalf("modelID = %q, want %q", modelID, "deepseek-v4-flash")
}
tc, ok := provider.(ThinkingCapable)
if !ok {
t.Fatalf("provider %T should implement ThinkingCapable for DeepSeek", provider)
}
if !tc.SupportsThinking() {
t.Fatalf("DeepSeek provider SupportsThinking() = false, want true")
}
}
func TestCreateProviderFromConfig_PreservesExplicitProviderPrefixedModel(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-openai",
@@ -846,8 +870,11 @@ func TestCreateProviderFromConfig_AzureMissingAPIKey(t *testing.T) {
}
_, _, err := CreateProviderFromConfig(cfg)
if err == nil {
t.Fatal("CreateProviderFromConfig() expected error for missing API key")
// Without api_key the factory falls back to identity auth, which in the
// default build is stubbed out and surfaces a build-tag error. With the
// azidentity tag, the call succeeds and is covered by a separate test.
if err != nil && !strings.Contains(err.Error(), "azidentity") {
t.Fatalf("CreateProviderFromConfig() unexpected error = %v", err)
}
}
@@ -1054,6 +1081,21 @@ func TestModelProviderOptions(t *testing.T) {
} else if option.DefaultAPIBase != "https://api.anthropic.com/v1" {
t.Fatalf("anthropic default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.anthropic.com/v1")
}
// First-party Claude API model IDs use hyphenated formats such as
// claude-{name}-{major}-{minor} or claude-{name}-{major}-{minor}-{YYYYMMDD};
// dotted provider prefixes are for platform-specific IDs such as Bedrock.
// https://platform.claude.com/docs/en/about-claude/models/model-ids-and-versions
for _, provider := range []string{"anthropic", "anthropic-messages"} {
option, ok := seen[provider]
if !ok {
t.Fatalf("%s option missing", provider)
}
for _, model := range option.CommonModels {
if strings.Contains(model, ".") {
t.Fatalf("%s common_model %q uses dotted ID", provider, model)
}
}
}
if _, ok := seen["azure"]; !ok {
t.Fatal("azure option missing")
}
+7
View File
@@ -89,6 +89,13 @@ func (p *HTTPProvider) SupportsNativeSearch() bool {
return p.delegate.SupportsNativeSearch()
}
func (p *HTTPProvider) SupportsThinking() bool {
if p == nil || p.delegate == nil {
return false
}
return p.delegate.SupportsThinking()
}
func (p *HTTPProvider) SetProviderName(providerName string) {
if p == nil || p.delegate == nil {
return
+19 -1
View File
@@ -104,8 +104,19 @@ func (p *CodexProvider) Chat(
defer stream.Close()
var resp *responses.Response
var streamedText strings.Builder
streamedOutputItems := make([]responses.ResponseOutputItemUnion, 0)
for stream.Next() {
evt := stream.Current()
if evt.Type == "response.output_text.delta" {
streamedText.WriteString(evt.Delta)
}
if evt.Type == "response.output_item.done" {
itemEvt := evt.AsResponseOutputItemDone()
if itemEvt.Item.Type != "" {
streamedOutputItems = append(streamedOutputItems, itemEvt.Item)
}
}
if evt.Type == "response.completed" || evt.Type == "response.failed" || evt.Type == "response.incomplete" {
evtResp := evt.Response
if evtResp.ID != "" {
@@ -152,8 +163,15 @@ func (p *CodexProvider) Chat(
logger.ErrorCF("provider.codex", "Codex stream ended without completed response event", fields)
return nil, fmt.Errorf("codex API call: stream ended without completed response")
}
if len(resp.Output) == 0 && len(streamedOutputItems) > 0 {
resp.Output = streamedOutputItems
}
return orc.ParseResponseFromStruct(resp), nil
parsed := orc.ParseResponseFromStruct(resp)
if parsed.Content == "" && streamedText.Len() > 0 {
parsed.Content = streamedText.String()
}
return parsed, nil
}
func (p *CodexProvider) GetDefaultModel() string {
+166
View File
@@ -374,6 +374,129 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) {
}
}
func TestCodexProvider_ChatRoundTrip_OutputTextDeltaFallback(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/responses" {
http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound)
return
}
var reqBody map[string]any
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if reqBody["stream"] != true {
http.Error(w, "stream must be true", http.StatusBadRequest)
return
}
resp := map[string]any{
"id": "resp_test",
"object": "response",
"status": "completed",
"output": nil,
}
writeOutputTextDeltaSSE(w, "OK", resp)
}))
defer server.Close()
provider := NewCodexProvider("test-token", "acc-123")
provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
resp, err := provider.Chat(
t.Context(),
[]Message{{Role: "user", Content: "Hello"}},
nil,
"gpt-4o",
map[string]any{},
)
if err != nil {
t.Fatalf("Chat() error: %v", err)
}
if resp.Content != "OK" {
t.Errorf("Content = %q, want %q", resp.Content, "OK")
}
}
func TestCodexProvider_ChatRoundTrip_OutputItemDoneFallback(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/responses" {
http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound)
return
}
var reqBody map[string]any
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if reqBody["stream"] != true {
http.Error(w, "stream must be true", http.StatusBadRequest)
return
}
item := map[string]any{
"id": "fc_1",
"type": "function_call",
"call_id": "call_abc",
"name": "write_file",
"arguments": `{"path":"x.txt","content":"ok"}`,
"status": "completed",
}
resp := map[string]any{
"id": "resp_test",
"object": "response",
"status": "completed",
"output": []map[string]any{},
}
writeOutputItemDoneSSE(w, item, resp)
}))
defer server.Close()
provider := NewCodexProvider("test-token", "acc-123")
provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
resp, err := provider.Chat(
t.Context(),
[]Message{{Role: "user", Content: "Create x.txt"}},
[]ToolDefinition{
{
Type: "function",
Function: ToolFunctionDefinition{
Name: "write_file",
Description: "write file",
Parameters: map[string]any{"type": "object"},
},
},
},
"gpt-5.5",
map[string]any{},
)
if err != nil {
t.Fatalf("Chat() error: %v", err)
}
if len(resp.ToolCalls) != 1 {
t.Fatalf("len(ToolCalls) = %d, want 1", len(resp.ToolCalls))
}
tc := resp.ToolCalls[0]
if tc.ID != "call_abc" {
t.Errorf("ToolCall.ID = %q, want %q", tc.ID, "call_abc")
}
if tc.Name != "write_file" {
t.Errorf("ToolCall.Name = %q, want %q", tc.Name, "write_file")
}
if tc.Arguments["path"] != "x.txt" {
t.Errorf("ToolCall.Arguments[path] = %v, want x.txt", tc.Arguments["path"])
}
if tc.Arguments["content"] != "ok" {
t.Errorf("ToolCall.Arguments[content] = %v, want ok", tc.Arguments["content"])
}
if resp.FinishReason != "tool_calls" {
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
}
}
func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/responses" {
@@ -647,3 +770,46 @@ func writeCompletedSSE(w http.ResponseWriter, response map[string]any) {
fmt.Fprintf(w, "data: %s\n\n", string(b))
fmt.Fprintf(w, "data: [DONE]\n\n")
}
func writeOutputTextDeltaSSE(w http.ResponseWriter, delta string, response map[string]any) {
deltaEvent := map[string]any{
"type": "response.output_text.delta",
"sequence_number": 1,
"delta": delta,
}
completedEvent := map[string]any{
"type": "response.completed",
"sequence_number": 2,
"response": response,
}
deltaBytes, _ := json.Marshal(deltaEvent)
completedBytes, _ := json.Marshal(completedEvent)
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprintf(w, "event: response.output_text.delta\n")
fmt.Fprintf(w, "data: %s\n\n", string(deltaBytes))
fmt.Fprintf(w, "event: response.completed\n")
fmt.Fprintf(w, "data: %s\n\n", string(completedBytes))
fmt.Fprintf(w, "data: [DONE]\n\n")
}
func writeOutputItemDoneSSE(w http.ResponseWriter, item map[string]any, response map[string]any) {
itemEvent := map[string]any{
"type": "response.output_item.done",
"sequence_number": 1,
"output_index": 0,
"item": item,
}
completedEvent := map[string]any{
"type": "response.completed",
"sequence_number": 2,
"response": response,
}
itemBytes, _ := json.Marshal(itemEvent)
completedBytes, _ := json.Marshal(completedEvent)
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprintf(w, "event: response.output_item.done\n")
fmt.Fprintf(w, "data: %s\n\n", string(itemBytes))
fmt.Fprintf(w, "event: response.completed\n")
fmt.Fprintf(w, "data: %s\n\n", string(completedBytes))
fmt.Fprintf(w, "data: [DONE]\n\n")
}
+37 -1
View File
@@ -14,6 +14,7 @@ import (
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers/common"
"github.com/sipeed/picoclaw/pkg/providers/messageutil"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
@@ -204,7 +205,16 @@ func (p *Provider) buildRequestBody(
func (p *Provider) applyThinkingControl(requestBody map[string]any, model string, options map[string]any) {
level, ok := normalizedThinkingLevel(options)
if !ok || level != "off" {
if !ok {
return
}
if p.SupportsThinking() {
p.applyDeepSeekThinkingControl(requestBody, level)
return
}
if level != "off" {
return
}
@@ -216,6 +226,28 @@ func (p *Provider) applyThinkingControl(requestBody map[string]any, model string
}
}
func (p *Provider) applyDeepSeekThinkingControl(requestBody map[string]any, level string) {
switch level {
case "off":
requestBody["thinking"] = map[string]any{"type": "disabled"}
case "low", "medium", "high":
requestBody["thinking"] = map[string]any{"type": "enabled"}
requestBody["reasoning_effort"] = "high"
case "xhigh":
requestBody["thinking"] = map[string]any{"type": "enabled"}
requestBody["reasoning_effort"] = "max"
case "adaptive":
logger.WarnCF("provider.openai_compat",
`DeepSeek does not support thinking_level="adaptive"; using provider default thinking behavior`,
map[string]any{
"provider": p.providerName,
"api_base": p.apiBase,
"thinking_level": level,
},
)
}
}
func normalizedThinkingLevel(options map[string]any) (string, bool) {
raw, ok := options["thinking_level"].(string)
if !ok {
@@ -290,6 +322,10 @@ func (p *Provider) SetProviderName(providerName string) {
p.providerName = strings.ToLower(strings.TrimSpace(providerName))
}
func (p *Provider) SupportsThinking() bool {
return strings.EqualFold(strings.TrimSpace(p.providerName), "deepseek") || isDeepSeekHost(p.apiBase)
}
func (p *Provider) prepareMessagesForRequest(messages []Message) []Message {
if len(messages) == 0 {
return nil
@@ -8,11 +8,13 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers/common"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
)
@@ -125,6 +127,146 @@ func TestBuildRequestBody_PreservesDoubaoRequestWhenThinkingLevelIsNotOff(t *tes
}
}
func TestBuildRequestBody_MapsDeepSeekThinkingLevels(t *testing.T) {
p := NewProvider("key", "https://api.deepseek.com/v1", "")
p.SetProviderName("deepseek")
tests := []struct {
name string
level string
wantThinkingType string
wantEffort any
}{
{name: "off", level: "off", wantThinkingType: "disabled"},
{name: "low", level: "low", wantThinkingType: "enabled", wantEffort: "high"},
{name: "medium", level: "medium", wantThinkingType: "enabled", wantEffort: "high"},
{name: "high", level: "high", wantThinkingType: "enabled", wantEffort: "high"},
{name: "xhigh", level: "xhigh", wantThinkingType: "enabled", wantEffort: "max"},
{name: "adaptive", level: "adaptive"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := p.buildRequestBody(
[]Message{{Role: "user", Content: "hi"}},
nil,
"deepseek-v4-pro",
map[string]any{"thinking_level": tt.level},
)
if tt.wantThinkingType == "" {
if _, ok := body["thinking"]; ok {
t.Fatalf("thinking should be omitted for %q, got %#v", tt.level, body["thinking"])
}
} else {
thinking, ok := body["thinking"].(map[string]any)
if !ok {
t.Fatalf("thinking = %#v, want map", body["thinking"])
}
if got := thinking["type"]; got != tt.wantThinkingType {
t.Fatalf("thinking.type = %#v, want %q", got, tt.wantThinkingType)
}
}
if tt.wantEffort == nil {
if _, ok := body["reasoning_effort"]; ok {
t.Fatalf("reasoning_effort should be omitted for %q, got %#v", tt.level, body["reasoning_effort"])
}
} else if got := body["reasoning_effort"]; got != tt.wantEffort {
t.Fatalf("reasoning_effort = %#v, want %#v", got, tt.wantEffort)
}
})
}
}
func TestBuildRequestBody_MapsDeepSeekThinkingLevelsByHost(t *testing.T) {
p := NewProvider("key", "https://api.deepseek.com/v1", "")
body := p.buildRequestBody(
[]Message{{Role: "user", Content: "hi"}},
nil,
"deepseek-v4-flash",
map[string]any{"thinking_level": "xhigh"},
)
thinking, ok := body["thinking"].(map[string]any)
if !ok {
t.Fatalf("thinking = %#v, want map", body["thinking"])
}
if got := thinking["type"]; got != "enabled" {
t.Fatalf("thinking.type = %#v, want enabled", got)
}
if got := body["reasoning_effort"]; got != "max" {
t.Fatalf("reasoning_effort = %#v, want max", got)
}
}
func TestBuildRequestBody_DeepSeekExtraBodyStillOverridesThinkingFields(t *testing.T) {
extraBody := map[string]any{
"thinking": map[string]any{"type": "disabled"},
"reasoning_effort": "max",
}
p := NewProvider("key", "https://api.deepseek.com/v1", "", WithExtraBody(extraBody))
p.SetProviderName("deepseek")
body := p.buildRequestBody(
[]Message{{Role: "user", Content: "hi"}},
nil,
"deepseek-v4-pro",
map[string]any{"thinking_level": "high"},
)
thinking, ok := body["thinking"].(map[string]any)
if !ok {
t.Fatalf("thinking = %#v, want map", body["thinking"])
}
if got := thinking["type"]; got != "disabled" {
t.Fatalf("thinking.type = %#v, want disabled from extra_body override", got)
}
if got := body["reasoning_effort"]; got != "max" {
t.Fatalf("reasoning_effort = %#v, want max from extra_body override", got)
}
}
func TestBuildRequestBody_WarnsForUnsupportedDeepSeekAdaptiveThinkingLevel(t *testing.T) {
logFile := t.TempDir() + "/deepseek-adaptive-warning.log"
prevLevel := logger.GetLevel()
logger.SetLevel(logger.WARN)
if err := logger.EnableFileLogging(logFile); err != nil {
t.Fatalf("EnableFileLogging() error = %v", err)
}
defer func() {
logger.DisableFileLogging()
logger.SetLevel(prevLevel)
}()
p := NewProvider("key", "https://api.deepseek.com/v1", "")
p.SetProviderName("deepseek")
body := p.buildRequestBody(
[]Message{{Role: "user", Content: "hi"}},
nil,
"deepseek-v4-pro",
map[string]any{"thinking_level": "adaptive"},
)
if _, ok := body["thinking"]; ok {
t.Fatalf("thinking should be omitted for adaptive, got %#v", body["thinking"])
}
if _, ok := body["reasoning_effort"]; ok {
t.Fatalf("reasoning_effort should be omitted for adaptive, got %#v", body["reasoning_effort"])
}
data, err := os.ReadFile(logFile)
if err != nil {
t.Fatalf("ReadFile(%q) error = %v", logFile, err)
}
logs := string(data)
if !strings.Contains(logs, `thinking_level=\"adaptive\"`) {
t.Fatalf("warning log = %q, want adaptive warning message", logs)
}
}
func TestProviderChat_ParsesToolCalls(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{
+3
View File
@@ -1,5 +1,7 @@
package protocoltypes
import "time"
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type,omitempty"`
@@ -87,6 +89,7 @@ type Message struct {
Role string `json:"role"`
Content string `json:"content"`
ModelName string `json:"model_name,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
Media []string `json:"media,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
+1
View File
@@ -474,6 +474,7 @@ var modelProviderOptionsByName = map[string]ModelProviderOption{
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 39,
CommonModels: []string{"mimo-v2.5", "mimo-v2.5-pro"},
httpAPI: true,
},
"avian": {
+98 -9
View File
@@ -68,17 +68,31 @@ func (a *Assembler) Assemble(ctx context.Context, convID int64, input AssembleIn
freshTailTokens += r.tokenCount
}
// Budget-aware selection of evictable items
// If the protected tail alone exceeds budget, trim from the oldest end at
// provider-safe boundaries. The rebuild path later sanitizes leading
// assistant(tool_calls)/tool messages, so splitting the active turn here can
// silently discard the very context we are trying to protect.
if freshTailTokens > input.Budget {
originalTailCount := len(freshTail)
originalFreshTailTokens := freshTailTokens
var preservedActiveTurn bool
freshTail, freshTailTokens, preservedActiveTurn = trimFreshTailToSafeBudget(freshTail, input.Budget)
logFields := map[string]any{
"budget": input.Budget,
"fresh_tail_tokens": freshTailTokens,
"fresh_tail_count": len(freshTail),
"trimmed_fresh_items": originalTailCount - len(freshTail),
"original_fresh_tokens": originalFreshTailTokens,
"preserved_active_turn": preservedActiveTurn,
}
if preservedActiveTurn {
logger.WarnCF("seahorse", "assemble: preserving active turn over budget", logFields)
} else {
logger.InfoCF("seahorse", "assemble: trimmed fresh tail to safe boundary", logFields)
}
}
remainingBudget := input.Budget - freshTailTokens
if remainingBudget < 0 {
// Fresh tail alone exceeds budget - we keep it anyway (design decision)
// Log for debugging retry/overflow issues
logger.InfoCF("seahorse", "assemble: fresh tail exceeds budget", map[string]any{
"budget": input.Budget,
"fresh_tail_tokens": freshTailTokens,
"fresh_tail_count": len(freshTail),
"over_budget_by": freshTailTokens - input.Budget,
})
remainingBudget = 0
}
@@ -184,6 +198,81 @@ func (a *Assembler) Assemble(ctx context.Context, convID int64, input AssembleIn
}, nil
}
func trimFreshTailToSafeBudget(tail []resolvedItem, budget int) ([]resolvedItem, int, bool) {
tailTokens := resolvedItemsTokenCount(tail)
if tailTokens <= budget {
return tail, tailTokens, false
}
latestTurnStart := lastUserMessageIndex(tail)
if latestTurnStart >= 0 {
latestTurnTokens := resolvedItemsTokenCount(tail[latestTurnStart:])
if latestTurnTokens > budget {
return tail[latestTurnStart:], latestTurnTokens, true
}
}
start := 0
for tailTokens > budget && start < len(tail) {
tailTokens -= tail[start].tokenCount
start++
}
for start < len(tail) && !isProviderSafeHistoryStart(tail[start:]) {
tailTokens -= tail[start].tokenCount
start++
}
return tail[start:], tailTokens, false
}
func resolvedItemsTokenCount(items []resolvedItem) int {
total := 0
for _, item := range items {
total += item.tokenCount
}
return total
}
func lastUserMessageIndex(items []resolvedItem) int {
for i := len(items) - 1; i >= 0; i-- {
if items[i].itemType != "message" || items[i].message == nil {
continue
}
if items[i].message.Role == "user" {
return i
}
}
return -1
}
func isProviderSafeHistoryStart(items []resolvedItem) bool {
for _, item := range items {
if item.itemType != "message" || item.message == nil {
continue
}
if item.message.Role == "tool" {
return false
}
if item.message.Role == "assistant" && messageHasToolUse(item.message) {
return false
}
return true
}
return true
}
func messageHasToolUse(msg *Message) bool {
if msg == nil {
return false
}
for _, part := range msg.Parts {
if part.Type == "tool_use" {
return true
}
}
return false
}
// resolveItem loads the full message or summary for a context item.
func (a *Assembler) resolveItem(ctx context.Context, item ContextItem) (resolvedItem, error) {
if item.ItemType == "message" {
+77 -8
View File
@@ -145,22 +145,91 @@ func TestAssemblerBudgetEvictsOldest(t *testing.T) {
s.UpsertContextItems(ctx, convID, items)
// Budget of 200 tokens with FreshTailCount=32
// Fresh tail = last 32 messages (320 tokens, over budget, but always included)
// Fresh tail = last 32 messages (320 tokens, over budget)
// Evictable = first 8 messages (80 tokens)
// Budget after tail: max(0, 200-320) = 0 → no evictable items included
// The oldest messages from the fresh tail should be dropped so only the
// newest 20 messages remain within the 200-token budget.
a := &Assembler{store: s, config: Config{}}
result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 200})
if err != nil {
t.Fatalf("Assemble: %v", err)
}
// Should only include the 32-item fresh tail
if len(result.Messages) != 32 {
t.Errorf("Messages = %d, want 32 (fresh tail)", len(result.Messages))
if len(result.Messages) != 20 {
t.Errorf("Messages = %d, want 20", len(result.Messages))
}
// Should be the LAST 32 messages
if result.Messages[0].ID != msgs[8].ID {
t.Errorf("first message ID = %d, want %d (msgs[8])", result.Messages[0].ID, msgs[8].ID)
if result.Messages[0].ID != msgs[20].ID {
t.Errorf("first message ID = %d, want %d (msgs[20])", result.Messages[0].ID, msgs[20].ID)
}
totalTokens := 0
for _, msg := range result.Messages {
totalTokens += msg.TokenCount
}
if totalTokens > 200 {
t.Errorf("assembled tokens = %d, want <= 200", totalTokens)
}
}
func TestAssemblerBudgetPreservesLatestToolTurnWhenItExceedsBudget(t *testing.T) {
s, convID := setupAssemblerStore(t)
ctx := context.Background()
oldMsg, _ := s.AddMessage(ctx, convID, "assistant", "older context", 20)
userMsg, _ := s.AddMessage(ctx, convID, "user", "inspect the file", 5)
assistantToolMsg, _ := s.AddMessageWithParts(ctx, convID, "assistant", []MessagePart{
{
Type: "tool_use",
Name: "read_file",
Arguments: `{"path":"/tmp/test.txt"}`,
ToolCallID: "tc_1",
},
}, 5)
toolResultMsg, _ := s.AddMessageWithParts(ctx, convID, "tool", []MessagePart{
{
Type: "tool_result",
ToolCallID: "tc_1",
Text: "very large tool output",
},
}, 200)
finalAssistantMsg, _ := s.AddMessage(ctx, convID, "assistant", "done", 5)
s.UpsertContextItems(ctx, convID, []ContextItem{
{Ordinal: 100, ItemType: "message", MessageID: oldMsg.ID, TokenCount: 20},
{Ordinal: 200, ItemType: "message", MessageID: userMsg.ID, TokenCount: 5},
{Ordinal: 300, ItemType: "message", MessageID: assistantToolMsg.ID, TokenCount: 5},
{Ordinal: 400, ItemType: "message", MessageID: toolResultMsg.ID, TokenCount: 200},
{Ordinal: 500, ItemType: "message", MessageID: finalAssistantMsg.ID, TokenCount: 5},
})
a := &Assembler{store: s, config: Config{}}
result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 210})
if err != nil {
t.Fatalf("Assemble: %v", err)
}
if len(result.Messages) != 4 {
t.Fatalf("Messages = %d, want 4 protected-turn messages", len(result.Messages))
}
if result.Messages[0].ID != userMsg.ID {
t.Fatalf("first message ID = %d, want current user message %d", result.Messages[0].ID, userMsg.ID)
}
if result.Messages[1].ID != assistantToolMsg.ID {
t.Fatalf("second message ID = %d, want assistant tool-call %d", result.Messages[1].ID, assistantToolMsg.ID)
}
if result.Messages[2].ID != toolResultMsg.ID {
t.Fatalf("third message ID = %d, want tool result %d", result.Messages[2].ID, toolResultMsg.ID)
}
if result.Messages[3].ID != finalAssistantMsg.ID {
t.Fatalf("fourth message ID = %d, want final assistant %d", result.Messages[3].ID, finalAssistantMsg.ID)
}
totalTokens := 0
for _, msg := range result.Messages {
totalTokens += msg.TokenCount
}
if totalTokens <= 210 {
t.Fatalf("assembled tokens = %d, want protected turn to remain over budget", totalTokens)
}
}
+108 -24
View File
@@ -9,6 +9,7 @@ import (
"regexp"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
@@ -261,6 +262,7 @@ func (e *Engine) Ingest(ctx context.Context, sessionKey string, messages []Messa
msg.ModelName,
msg.ReasoningContent,
msg.TokenCount,
msg.CreatedAt,
)
} else {
added, err = e.store.AddMessageWithReasoning(
@@ -271,6 +273,7 @@ func (e *Engine) Ingest(ctx context.Context, sessionKey string, messages []Messa
msg.ModelName,
msg.ReasoningContent,
msg.TokenCount,
msg.CreatedAt,
)
}
if err != nil {
@@ -445,10 +448,14 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me
if err != nil {
return fmt.Errorf("bootstrap: repair model_name: %w", err)
}
if (repairedReasoning || repairedModelName) && len(dbMsgs) == len(messages) {
repairedCreatedAt, err := e.repairBootstrapCreatedAt(ctx, dbMsgs, messages)
if err != nil {
return fmt.Errorf("bootstrap: repair created_at: %w", err)
}
if (repairedReasoning || repairedModelName || repairedCreatedAt) && len(dbMsgs) == len(messages) {
matched := true
for i := range messages {
if !messageMatches(dbMsgs[i], messages[i]) {
if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{}) {
matched = false
break
}
@@ -462,7 +469,7 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me
if len(dbMsgs) == len(messages) {
matched := true
for i := range messages {
if !messageMatches(dbMsgs[i], messages[i]) {
if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{}) {
matched = false
break
}
@@ -477,7 +484,7 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me
compareLen := min(len(dbMsgs), len(messages))
for i := range compareLen {
if messageMatches(dbMsgs[i], messages[i]) {
if messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{}) {
anchor = i
} else {
// Mismatch detected - log details and rebuild
@@ -578,7 +585,11 @@ func (e *Engine) repairBootstrapReasoningContent(ctx context.Context, dbMsgs, me
}
for i := range overlap {
if !messageMatchesIgnoringReasoningAndModelName(dbMsgs[i], messages[i]) {
if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{
IgnoreReasoningContent: true,
IgnoreModelName: true,
IgnoreCreatedAt: true,
}) {
return false, nil
}
if dbMsgs[i].ReasoningContent == messages[i].ReasoningContent {
@@ -629,7 +640,11 @@ func (e *Engine) repairBootstrapModelName(ctx context.Context, dbMsgs, messages
}
for i := range overlap {
if !messageMatchesIgnoringReasoningAndModelName(dbMsgs[i], messages[i]) {
if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{
IgnoreReasoningContent: true,
IgnoreModelName: true,
IgnoreCreatedAt: true,
}) {
return false, nil
}
if dbMsgs[i].ModelName == messages[i].ModelName {
@@ -666,6 +681,64 @@ func (e *Engine) repairBootstrapModelName(ctx context.Context, dbMsgs, messages
return true, nil
}
func (e *Engine) repairBootstrapCreatedAt(ctx context.Context, dbMsgs, messages []Message) (bool, error) {
if len(dbMsgs) == 0 || len(messages) == 0 {
return false, nil
}
overlap := min(len(messages), len(dbMsgs))
var updates []struct {
index int
messageID int64
createdAt time.Time
}
for i := range overlap {
if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{
IgnoreReasoningContent: true,
IgnoreModelName: true,
IgnoreCreatedAt: true,
}) {
return false, nil
}
wantCreatedAt := normalizeMessageCreatedAt(messages[i].CreatedAt)
if wantCreatedAt.IsZero() {
return false, nil
}
if dbMsgs[i].CreatedAt.Equal(wantCreatedAt) {
continue
}
updates = append(updates, struct {
index int
messageID int64
createdAt time.Time
}{
index: i,
messageID: dbMsgs[i].ID,
createdAt: wantCreatedAt,
})
}
if len(updates) == 0 {
return false, nil
}
for _, update := range updates {
if err := e.store.UpdateMessageCreatedAt(ctx, update.messageID, update.createdAt); err != nil {
return false, err
}
dbMsgs[update.index].CreatedAt = update.createdAt
}
logger.InfoCF("seahorse", "bootstrap: repaired message created_at", map[string]any{
"messages": len(updates),
})
return true, nil
}
// truncate shortens a string for logging.
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
@@ -674,29 +747,28 @@ func truncate(s string, maxLen int) string {
return s[:maxLen] + "..."
}
// messageMatches compares two messages using role + reasoning_content and then
// either content or parts. TokenCount is NOT compared because it may be
// re-estimated differently during bootstrap (e.g., via tokenizer.EstimateMessageTokens).
// For messages with Parts (tool_use, tool_result), compare Parts instead of Content
// because structured messages are matched by their parts payload.
func messageMatches(a, b Message) bool {
if a.Role != b.Role || a.ReasoningContent != b.ReasoningContent || a.ModelName != b.ModelName {
return false
}
return messageMatchesIgnoringReasoning(a, b)
type messageMatchOptions struct {
IgnoreReasoningContent bool
IgnoreModelName bool
IgnoreCreatedAt bool
}
func messageMatchesIgnoringReasoning(a, b Message) bool {
if a.ModelName != b.ModelName {
return false
}
return messageMatchesIgnoringReasoningAndModelName(a, b)
}
func messageMatchesIgnoringReasoningAndModelName(a, b Message) bool {
// messagesMatch compares two messages by role and payload, plus the optional
// metadata fields used by bootstrap repair. TokenCount is intentionally ignored
// because bootstrap may re-estimate it differently.
func messagesMatch(a, b Message, opts messageMatchOptions) bool {
if a.Role != b.Role {
return false
}
if !opts.IgnoreReasoningContent && a.ReasoningContent != b.ReasoningContent {
return false
}
if !opts.IgnoreModelName && a.ModelName != b.ModelName {
return false
}
if !opts.IgnoreCreatedAt && !messageCreatedAtMatches(a.CreatedAt, b.CreatedAt) {
return false
}
// If either message has Parts, compare Parts
if len(a.Parts) > 0 || len(b.Parts) > 0 {
return partsMatch(a.Parts, b.Parts)
@@ -705,6 +777,18 @@ func messageMatchesIgnoringReasoningAndModelName(a, b Message) bool {
return a.Content == b.Content
}
// messageCreatedAtMatches treats missing timestamps as compatible so bootstrap
// can preserve legacy histories while still enforcing exact equality once both
// sides carry canonical created_at values.
func messageCreatedAtMatches(a, b time.Time) bool {
na := normalizeMessageCreatedAt(a)
nb := normalizeMessageCreatedAt(b)
if na.IsZero() || nb.IsZero() {
return true
}
return na.Equal(nb)
}
// partsMatch compares two slices of MessagePart for equality.
func partsMatch(a, b []MessagePart) bool {
if len(a) != len(b) {
+81 -5
View File
@@ -57,8 +57,8 @@ func prepareBootstrapRepairConversation(
}
return conv, []Message{
{Role: "user", Content: "hello", TokenCount: 3},
{Role: "assistant", Content: "world", TokenCount: 3},
{Role: "user", Content: "hello", TokenCount: 3, CreatedAt: userMsg.CreatedAt},
{Role: "assistant", Content: "world", TokenCount: 3, CreatedAt: assistantMsg.CreatedAt},
}
}
@@ -464,13 +464,19 @@ func TestBootstrapRepairsReasoningContentAndModelNameTogether(t *testing.T) {
}
err = eng.Bootstrap(ctx, sessionKey, []Message{
{Role: "user", Content: "hello", TokenCount: 3},
{
Role: "user",
Content: "hello",
TokenCount: 3,
CreatedAt: time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC),
},
{
Role: "assistant",
Content: "world",
ModelName: "gpt-5.4",
ReasoningContent: "let me think this through",
TokenCount: 3,
CreatedAt: time.Date(2026, 3, 4, 5, 6, 8, 0, time.UTC),
},
})
if err != nil {
@@ -515,6 +521,7 @@ func TestBootstrapRepairsIncorrectNonEmptyModelName(t *testing.T) {
"wrong-model",
"",
3,
time.Time{},
)
if err != nil {
t.Fatalf("AddMessageWithReasoning assistant: %v", err)
@@ -545,6 +552,64 @@ func TestBootstrapRepairsIncorrectNonEmptyModelName(t *testing.T) {
}
}
func TestBootstrapRepairsCreatedAt(t *testing.T) {
eng := newTestEngine(t)
ctx := context.Background()
sessionKey := "agent:repair-created-at"
conv, msgs := prepareBootstrapRepairConversation(t, eng, ctx, sessionKey)
wantCreatedAt := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC)
msgs[1].CreatedAt = wantCreatedAt
err := eng.Bootstrap(ctx, sessionKey, msgs)
if err != nil {
t.Fatalf("Bootstrap: %v", err)
}
stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
if err != nil {
t.Fatalf("GetMessages: %v", err)
}
if len(stored) != 2 {
t.Fatalf("stored messages = %d, want 2", len(stored))
}
if !stored[1].CreatedAt.Equal(wantCreatedAt) {
t.Fatalf("stored[1].CreatedAt = %v, want %v", stored[1].CreatedAt, wantCreatedAt)
}
}
func TestEngineIngestPreservesCreatedAt(t *testing.T) {
eng := newTestEngine(t)
ctx := context.Background()
wantCreatedAt := time.Date(2026, 4, 5, 6, 7, 8, 0, time.UTC)
msgs := []Message{
{
Role: "assistant",
Content: "world",
TokenCount: 4,
CreatedAt: wantCreatedAt,
},
}
_, err := eng.Ingest(ctx, "agent:created-at", msgs)
if err != nil {
t.Fatalf("Ingest: %v", err)
}
conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:created-at")
stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
if err != nil {
t.Fatalf("GetMessages: %v", err)
}
if len(stored) != 1 {
t.Fatalf("stored messages = %d, want 1", len(stored))
}
if !stored[0].CreatedAt.Equal(wantCreatedAt) {
t.Fatalf("stored[0].CreatedAt = %v, want %v", stored[0].CreatedAt, wantCreatedAt)
}
}
func TestEngineIngestWithPartsPreservesReasoningContent(t *testing.T) {
eng := newTestEngine(t)
ctx := context.Background()
@@ -864,8 +929,19 @@ func TestBootstrapRepairsMissingReasoningContentWithoutDroppingSummaries(t *test
}
err = eng.Bootstrap(ctx, sessionKey, []Message{
{Role: "user", Content: "hello", TokenCount: 3},
{Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3},
{
Role: "user",
Content: "hello",
TokenCount: 3,
CreatedAt: time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC),
},
{
Role: "assistant",
Content: "world",
ReasoningContent: "let me think this through",
TokenCount: 3,
CreatedAt: time.Date(2026, 3, 4, 5, 6, 8, 0, time.UTC),
},
})
if err != nil {
t.Fatalf("Bootstrap: %v", err)
+4 -1
View File
@@ -22,7 +22,10 @@ func ParseLastDuration(s string) (time.Duration, error) {
return 0, fmt.Errorf("invalid duration format: %q (use format like 6h, 7d, 2w, 1m)", s)
}
value, _ := strconv.Atoi(matches[1])
value, err := strconv.Atoi(matches[1])
if err != nil {
return 0, fmt.Errorf("invalid duration value: %q", matches[1])
}
unit := matches[2]
switch unit {

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