` — remove a server entry
+- `picoclaw mcp edit` — open `config.json` in `$EDITOR` for advanced edits
+
+These commands manage the same `tools.mcp.servers` section documented below. See [MCP Server CLI](mcp-cli.md) for command syntax, examples, and behavior details.
+
### Tool Discovery (Lazy Loading)
When connecting to multiple MCP servers, exposing hundreds of tools simultaneously can exhaust the LLM's context window
diff --git a/go.mod b/go.mod
index a8b540662..4afbe9d85 100644
--- a/go.mod
+++ b/go.mod
@@ -9,9 +9,9 @@ require (
github.com/adhocore/gronx v1.19.6
github.com/anthropics/anthropic-sdk-go v1.26.0
github.com/atc0005/go-teams-notify/v2 v2.14.0
- github.com/aws/aws-sdk-go-v2 v1.41.5
- github.com/aws/aws-sdk-go-v2/config v1.32.14
- github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
+ github.com/aws/aws-sdk-go-v2 v1.41.6
+ github.com/aws/aws-sdk-go-v2/config v1.32.16
+ github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.5
github.com/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.4.0
github.com/charmbracelet/lipgloss v1.1.0
@@ -23,7 +23,7 @@ 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.5.3
+ github.com/larksuite/oapi-sdk-go/v3 v3.5.4
github.com/mdp/qrterminal/v3 v3.2.1
github.com/minio/selfupdate v0.6.0
github.com/modelcontextprotocol/go-sdk v1.5.0
@@ -34,7 +34,7 @@ require (
github.com/pion/rtp v1.10.1
github.com/pion/webrtc/v3 v3.3.6
github.com/rivo/tview v0.42.0
- github.com/rs/zerolog v1.35.0
+ github.com/rs/zerolog v1.35.1
github.com/slack-go/slack v0.17.3
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
@@ -55,19 +55,19 @@ require (
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.8 // indirect
- github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect
- github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
- github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
- github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
- github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
- github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
- github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
- github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
- github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
- github.com/aws/smithy-go v1.24.2 // indirect
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect
+ github.com/aws/aws-sdk-go-v2/credentials v1.19.15 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect
+ github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect
+ github.com/aws/smithy-go v1.25.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beeper/argo-go v1.1.2 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
diff --git a/go.sum b/go.sum
index f63c7b44e..19547816d 100644
--- a/go.sum
+++ b/go.sum
@@ -23,38 +23,38 @@ github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAf
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
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.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
-github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
-github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
-github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
-github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
-github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
-github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 h1:W6tKfa/s37faUnwJ71pGqsBO7/wfUX1L7tVprupQGo4=
-github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4/go.mod h1:BZ+9thH0QOTDUwE8KAv/ZwUzsNC7CSMJXj/wtnZMs5k=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
-github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
-github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
-github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
-github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
-github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
-github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
-github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
-github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
+github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg=
+github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9/go.mod h1:uOYhgfgThm/ZyAuJGNQ5YgNyOlYfqnGpTHXvk3cpykg=
+github.com/aws/aws-sdk-go-v2/config v1.32.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM=
+github.com/aws/aws-sdk-go-v2/config v1.32.16/go.mod h1:duCCnJEFqpt2RC6no1iK6q+8HpwOAkiUua0pY507dQc=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 h1:IOGsJ1xVWhsi+ZO7/NW8OuZZBtMJLZbk4P5HDjJO0jQ=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22/go.mod h1:b+hYdbU+jGKfXE8kKM6g1+h+L/Go3vMvzlxBsiuGsxg=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U=
+github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.5 h1:ZGTl4Rxft1uyENAlGESY04hMzE4cLLNUPI7dGw08haw=
+github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.5/go.mod h1:jnugA+VgESQGgXuEKK6zVToET/DtODq7LQYpe+BkKT4=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.16/go.mod h1:CudnEVKRtLn0+3uMV0yEXZ+YZOKnAtUJ5DmDhilVnIw=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 h1:oK/njaL8GtyEihkWMD4k3VgHCT64RQKkZwh0DG5j8ak=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20/go.mod h1:JHs8/y1f3zY7U5WcuzoJ/yAYGYtNIVPKLIbp61euvmg=
+github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds=
+github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo=
+github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U=
+github.com/aws/smithy-go v1.25.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/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
@@ -138,8 +138,6 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
-github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207 h1:p7t34F7K4OCRQblcDhNJnP46Uaarz3z2cLcvOZYxWn8=
github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -185,8 +183,8 @@ 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.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
-github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
+github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
+github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
@@ -243,8 +241,8 @@ github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
-github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
-github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
+github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
+github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
diff --git a/pkg/agent/agent_mcp.go b/pkg/agent/agent_mcp.go
index 251d32b58..fcb57a5d4 100644
--- a/pkg/agent/agent_mcp.go
+++ b/pkg/agent/agent_mcp.go
@@ -135,6 +135,25 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
serverCfg := al.cfg.Tools.MCP.Servers[serverName]
registerAsHidden := serverIsDeferred(al.cfg.Tools.MCP.Discovery.Enabled, serverCfg)
+ for _, agentID := range agentIDs {
+ agent, ok := al.registry.GetAgent(agentID)
+ if !ok || agent.ContextBuilder == nil {
+ continue
+ }
+ if err := agent.ContextBuilder.RegisterPromptContributor(mcpServerPromptContributor{
+ serverName: serverName,
+ toolCount: len(conn.Tools),
+ deferred: registerAsHidden,
+ }); err != nil {
+ logger.WarnCF("agent", "Failed to register MCP prompt contributor",
+ map[string]any{
+ "agent_id": agentID,
+ "server": serverName,
+ "error": err.Error(),
+ })
+ }
+ }
+
for _, tool := range conn.Tools {
for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID)
diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go
index b0aa3b468..030b65a6d 100644
--- a/pkg/agent/agent_test.go
+++ b/pkg/agent/agent_test.go
@@ -1965,6 +1965,17 @@ func TestToolFeedbackExplanationFromResponse_DoesNotUseReasoningContent(t *testi
}
}
+func TestToolFeedbackArgsPreview_UsesJSONAndTruncates(t *testing.T) {
+ got := toolFeedbackArgsPreview(map[string]any{
+ "path": "README.md",
+ "limit": 42,
+ }, 128)
+ want := "{\n \"limit\": 42,\n \"path\": \"README.md\"\n}"
+ if got != want {
+ t.Fatalf("toolFeedbackArgsPreview() = %q, want %q", got, want)
+ }
+}
+
type picoInterleavedContentProvider struct {
calls int
}
@@ -3940,6 +3951,12 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
if !strings.Contains(outbound.Content, "check tool feedback") {
t.Fatalf("tool feedback content = %q, want current user intent fallback", outbound.Content)
}
+ if !strings.Contains(outbound.Content, "\"path\":") {
+ t.Fatalf("tool feedback content = %q, want serialized tool arguments", outbound.Content)
+ }
+ if !strings.Contains(outbound.Content, heartbeatFile) {
+ t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content)
+ }
if strings.Contains(outbound.Content, "Previous turn explanation") {
t.Fatalf("tool feedback content = %q, want no previous assistant fallback", outbound.Content)
}
@@ -4012,6 +4029,12 @@ func TestProcessMessage_DoesNotLeakReasoningContentInToolFeedback(t *testing.T)
if !strings.Contains(outbound.Content, "check reasoning fallback") {
t.Fatalf("tool feedback content = %q, want current user intent fallback", outbound.Content)
}
+ if !strings.Contains(outbound.Content, "\"path\":") {
+ t.Fatalf("tool feedback content = %q, want serialized tool arguments", outbound.Content)
+ }
+ if !strings.Contains(outbound.Content, heartbeatFile) {
+ t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content)
+ }
if strings.Contains(outbound.Content, "Read README.md first") {
t.Fatalf("tool feedback content = %q, should not leak hidden reasoning", outbound.Content)
}
@@ -4310,7 +4333,7 @@ func TestRun_PicoToolFeedbackSuppressesDuplicateInterimAssistantContent(t *testi
}
}
- if outputs[0] != "🔧 `tool_limit_test_tool`\nintermediate model text" {
+ if outputs[0] != "🔧 `tool_limit_test_tool`\nintermediate model text\n```json\n{\n \"value\": \"x\"\n}\n```" {
t.Fatalf("first outbound content = %q, want tool feedback summary", outputs[0])
}
if outputs[1] != "final model text" {
diff --git a/pkg/agent/agent_utils.go b/pkg/agent/agent_utils.go
index ff98dad68..17fb9dd1f 100644
--- a/pkg/agent/agent_utils.go
+++ b/pkg/agent/agent_utils.go
@@ -4,6 +4,7 @@ package agent
import (
"context"
+ "encoding/json"
"fmt"
"path/filepath"
"strings"
@@ -170,6 +171,18 @@ func toolFeedbackExplanationFromMessages(messages []providers.Message) string {
return ""
}
+func toolFeedbackArgsPreview(args map[string]any, maxLen int) string {
+ if args == nil {
+ args = map[string]any{}
+ }
+
+ argsJSON, err := json.MarshalIndent(args, "", " ")
+ if err != nil {
+ return utils.Truncate(fmt.Sprintf("%v", args), maxLen)
+ }
+ return utils.Truncate(string(argsJSON), maxLen)
+}
+
func shouldPublishToolFeedback(cfg *config.Config, ts *turnState) bool {
if ts == nil || ts.channel == "" || ts.opts.SuppressToolFeedback {
return false
diff --git a/pkg/agent/context.go b/pkg/agent/context.go
index 1e5a75d92..ecde7c33e 100644
--- a/pkg/agent/context.go
+++ b/pkg/agent/context.go
@@ -1,6 +1,7 @@
package agent
import (
+ "context"
"errors"
"fmt"
"io/fs"
@@ -21,12 +22,11 @@ import (
)
type ContextBuilder struct {
- workspace string
- skillsLoader *skills.SkillsLoader
- memory *MemoryStore
- toolDiscoveryBM25 bool
- toolDiscoveryRegex bool
- splitOnMarker bool
+ workspace string
+ skillsLoader *skills.SkillsLoader
+ memory *MemoryStore
+ splitOnMarker bool
+ promptRegistry *PromptRegistry
// Cache for system prompt to avoid rebuilding on every call.
// This fixes issue #607: repeated reprocessing of the entire context.
@@ -48,8 +48,16 @@ type ContextBuilder struct {
}
func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuilder {
- cb.toolDiscoveryBM25 = useBM25
- cb.toolDiscoveryRegex = useRegex
+ if useBM25 || useRegex {
+ if err := cb.RegisterPromptContributor(toolDiscoveryPromptContributor{
+ useBM25: useBM25,
+ useRegex: useRegex,
+ }); err != nil {
+ logger.WarnCF("agent", "Failed to register tool discovery prompt contributor", map[string]any{
+ "error": err.Error(),
+ })
+ }
+ }
return cb
}
@@ -73,15 +81,38 @@ func NewContextBuilder(workspace string) *ContextBuilder {
globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills")
return &ContextBuilder{
- workspace: workspace,
- skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
- memory: NewMemoryStore(workspace),
+ workspace: workspace,
+ skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
+ memory: NewMemoryStore(workspace),
+ promptRegistry: NewPromptRegistry(),
}
}
+func (cb *ContextBuilder) RegisterPromptSource(desc PromptSourceDescriptor) error {
+ err := cb.promptRegistryOrDefault().RegisterSource(desc)
+ if err == nil {
+ cb.InvalidateCache()
+ }
+ return err
+}
+
+func (cb *ContextBuilder) RegisterPromptContributor(contributor PromptContributor) error {
+ err := cb.promptRegistryOrDefault().RegisterContributor(contributor)
+ if err == nil {
+ cb.InvalidateCache()
+ }
+ return err
+}
+
+func (cb *ContextBuilder) promptRegistryOrDefault() *PromptRegistry {
+ if cb.promptRegistry == nil {
+ cb.promptRegistry = NewPromptRegistry()
+ }
+ return cb.promptRegistry
+}
+
func (cb *ContextBuilder) getIdentity() string {
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
- toolDiscovery := cb.getDiscoveryRule()
version := config.FormatVersion()
return fmt.Sprintf(
@@ -103,22 +134,20 @@ Your workspace is at: %s
3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md
-4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.
-
-%s`,
- version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery)
+4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.`,
+ version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath)
}
-func (cb *ContextBuilder) getDiscoveryRule() string {
- if !cb.toolDiscoveryBM25 && !cb.toolDiscoveryRegex {
+func formatToolDiscoveryRule(useBM25, useRegex bool) string {
+ if !useBM25 && !useRegex {
return ""
}
var toolNames []string
- if cb.toolDiscoveryBM25 {
+ if useBM25 {
toolNames = append(toolNames, `"tool_search_tool_bm25"`)
}
- if cb.toolDiscoveryRegex {
+ if useRegex {
toolNames = append(toolNames, `"tool_search_tool_regex"`)
}
@@ -129,43 +158,103 @@ func (cb *ContextBuilder) getDiscoveryRule() string {
}
func (cb *ContextBuilder) BuildSystemPrompt() string {
- parts := []string{}
+ return renderPromptPartsLegacy(cb.BuildSystemPromptParts())
+}
+
+func (cb *ContextBuilder) BuildSystemPromptParts() []PromptPart {
+ stack := NewPromptStack(cb.promptRegistryOrDefault())
+ add := func(part PromptPart) {
+ if err := stack.Add(part); err != nil {
+ logger.WarnCF("agent", "Skipping invalid prompt part", map[string]any{
+ "id": part.ID,
+ "layer": part.Layer,
+ "slot": part.Slot,
+ "source": part.Source.ID,
+ "error": err.Error(),
+ })
+ }
+ }
// Core identity section
- parts = append(parts, cb.getIdentity())
+ add(PromptPart{
+ ID: "kernel.identity",
+ Layer: PromptLayerKernel,
+ Slot: PromptSlotIdentity,
+ Source: PromptSource{ID: PromptSourceKernel, Name: "identity"},
+ Title: "picoclaw identity",
+ Content: cb.getIdentity(),
+ Stable: true,
+ Cache: PromptCacheEphemeral,
+ })
// Bootstrap files
bootstrapContent := cb.LoadBootstrapFiles()
if bootstrapContent != "" {
- parts = append(parts, bootstrapContent)
+ add(PromptPart{
+ ID: "instruction.workspace",
+ Layer: PromptLayerInstruction,
+ Slot: PromptSlotWorkspace,
+ Source: PromptSource{ID: PromptSourceWorkspace, Name: "workspace"},
+ Title: "workspace instructions",
+ Content: bootstrapContent,
+ Stable: true,
+ Cache: PromptCacheEphemeral,
+ })
}
// Skills - show summary, AI can read full content with read_file tool
skillsSummary := cb.skillsLoader.BuildSkillsSummary()
if skillsSummary != "" {
- parts = append(parts, fmt.Sprintf(`# Skills
+ add(PromptPart{
+ ID: "capability.skill_catalog",
+ Layer: PromptLayerCapability,
+ Slot: PromptSlotSkillCatalog,
+ Source: PromptSource{ID: PromptSourceSkillCatalog, Name: "skill:index"},
+ Title: "skill catalog",
+ Content: fmt.Sprintf(`# Skills
The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.
-%s`, skillsSummary))
+%s`, skillsSummary),
+ Stable: true,
+ Cache: PromptCacheEphemeral,
+ })
}
// Memory context
memoryContext := cb.memory.GetMemoryContext()
if memoryContext != "" {
- parts = append(parts, "# Memory\n\n"+memoryContext)
+ add(PromptPart{
+ ID: "context.memory",
+ Layer: PromptLayerContext,
+ Slot: PromptSlotMemory,
+ Source: PromptSource{ID: PromptSourceMemory, Name: "memory:workspace"},
+ Title: "memory",
+ Content: "# Memory\n\n" + memoryContext,
+ Stable: true,
+ Cache: PromptCacheEphemeral,
+ })
}
// Multi-Message Sending (if enabled)
if cb.splitOnMarker {
- parts = append(parts, `# MULTI-MESSAGE OUTPUT
+ add(PromptPart{
+ ID: "context.output_policy.split_on_marker",
+ Layer: PromptLayerContext,
+ Slot: PromptSlotOutput,
+ Source: PromptSource{ID: PromptSourceOutputPolicy, Name: "split_on_marker"},
+ Title: "multi-message output policy",
+ Content: `# MULTI-MESSAGE OUTPUT
You MUST frequently use <|[SPLIT]|> to break your responses into multiple short messages. NEVER output a single long wall of text. Actively split distinct concepts or parts. Example: Message part 1<|[SPLIT]|>Message part 2<|[SPLIT]|>Message part 3
-Each part separated by the marker will be sent as an independent message.`)
+Each part separated by the marker will be sent as an independent message.`,
+ Stable: true,
+ Cache: PromptCacheEphemeral,
+ })
}
- // Join with "---" separator
- return strings.Join(parts, "\n\n---\n\n")
+ stack.Seal()
+ return stack.Parts()
}
// BuildSystemPromptWithCache returns the cached system prompt if available
@@ -230,6 +319,19 @@ func (cb *ContextBuilder) EstimateSystemTokens(summary string, activeSkills []st
totalChars += 7 // separator \n\n---\n\n
}
+ if contributedParts, err := cb.promptRegistryOrDefault().Collect(context.Background(), PromptBuildRequest{
+ Summary: summary,
+ ActiveSkills: append([]string(nil), activeSkills...),
+ }); err == nil {
+ for _, part := range contributedParts {
+ if strings.TrimSpace(part.Content) == "" {
+ continue
+ }
+ totalChars += utf8.RuneCountInString(part.Content)
+ totalChars += 7 // separator
+ }
+ }
+
if summary != "" {
// Matches the CONTEXT_SUMMARY: prefix added in BuildMessages
const summaryPrefix = "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation " +
@@ -548,6 +650,20 @@ func (cb *ContextBuilder) BuildMessages(
channel, chatID, senderID, senderDisplayName string,
activeSkills ...string,
) []providers.Message {
+ return cb.BuildMessagesFromPrompt(PromptBuildRequest{
+ History: history,
+ Summary: summary,
+ CurrentMessage: currentMessage,
+ Media: media,
+ Channel: channel,
+ ChatID: chatID,
+ SenderID: senderID,
+ SenderDisplayName: senderDisplayName,
+ ActiveSkills: append([]string(nil), activeSkills...),
+ })
+}
+
+func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []providers.Message {
messages := []providers.Message{}
// The static part (identity, bootstrap, skills, memory) is cached locally to
@@ -562,7 +678,7 @@ func (cb *ContextBuilder) BuildMessages(
staticPrompt := cb.BuildSystemPromptWithCache()
// Build short dynamic context (time, runtime, session) — changes per request
- dynamicCtx := cb.buildDynamicContext(channel, chatID, senderID, senderDisplayName)
+ dynamicCtx := cb.buildDynamicContext(req.Channel, req.ChatID, req.SenderID, req.SenderDisplayName)
// Compose a single system message: static (cached) + dynamic + optional summary.
// Keeping all system content in one message ensures every provider adapter can
@@ -573,25 +689,77 @@ func (cb *ContextBuilder) BuildMessages(
// cache-aware adapters (Anthropic) can set per-block cache_control.
// The static block is marked "ephemeral" — its prefix hash is stable
// across requests, enabling LLM-side KV cache reuse.
- stringParts := []string{staticPrompt, dynamicCtx}
+ stringParts := []string{staticPrompt}
contentBlocks := []providers.ContentBlock{
- {Type: "text", Text: staticPrompt, CacheControl: &providers.CacheControl{Type: "ephemeral"}},
- {Type: "text", Text: dynamicCtx},
+ promptContentBlock(PromptPart{
+ ID: "kernel.static",
+ Layer: PromptLayerKernel,
+ Slot: PromptSlotIdentity,
+ Source: PromptSource{ID: PromptSourceKernel, Name: "static"},
+ Content: staticPrompt,
+ }, &providers.CacheControl{Type: "ephemeral"}),
}
- if skillsText := cb.buildActiveSkillsContext(activeSkills); skillsText != "" {
- stringParts = append(stringParts, skillsText)
- contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: skillsText})
+ promptParts := append([]PromptPart(nil), req.Overlays...)
+ promptParts = append(promptParts, cb.buildActiveSkillsPromptParts(req.ActiveSkills)...)
+ if contributedParts, err := cb.promptRegistryOrDefault().Collect(context.Background(), req); err != nil {
+ logger.WarnCF("agent", "Prompt contributor collection failed", map[string]any{
+ "error": err.Error(),
+ })
+ } else {
+ promptParts = append(promptParts, contributedParts...)
}
- if summary != "" {
- summaryText := fmt.Sprintf(
- "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+
- "for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s",
- summary)
- stringParts = append(stringParts, summaryText)
- contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: summaryText})
+ if len(promptParts) > 0 {
+ for _, overlay := range sortPromptParts(promptParts) {
+ if strings.TrimSpace(overlay.Content) == "" {
+ continue
+ }
+ if err := cb.promptRegistryOrDefault().ValidatePart(overlay); err != nil {
+ logger.WarnCF("agent", "Skipping invalid prompt overlay", map[string]any{
+ "id": overlay.ID,
+ "layer": overlay.Layer,
+ "slot": overlay.Slot,
+ "source": overlay.Source.ID,
+ "error": err.Error(),
+ })
+ continue
+ }
+ stringParts = append(stringParts, overlay.Content)
+ contentBlocks = append(contentBlocks, promptContentBlock(overlay, nil))
+ }
+ }
+
+ runtimePart := PromptPart{
+ ID: "context.runtime",
+ Layer: PromptLayerContext,
+ Slot: PromptSlotRuntime,
+ Source: PromptSource{ID: PromptSourceRuntime, Name: "runtime"},
+ Title: "runtime context",
+ Content: dynamicCtx,
+ Stable: false,
+ Cache: PromptCacheNone,
+ }
+ stringParts = append(stringParts, dynamicCtx)
+ contentBlocks = append(contentBlocks, promptContentBlock(runtimePart, nil))
+
+ if req.Summary != "" {
+ summaryPart := PromptPart{
+ ID: "context.summary",
+ Layer: PromptLayerContext,
+ Slot: PromptSlotSummary,
+ Source: PromptSource{ID: PromptSourceSummary, Name: "context.summary"},
+ Title: "context summary",
+ Content: fmt.Sprintf(
+ "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+
+ "for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s",
+ req.Summary),
+ Stable: false,
+ Cache: PromptCacheNone,
+ }
+ stringParts = append(stringParts, summaryPart.Content)
+ contentBlocks = append(contentBlocks, promptContentBlock(summaryPart, nil))
}
fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n")
@@ -608,7 +776,8 @@ func (cb *ContextBuilder) BuildMessages(
"static_chars": len(staticPrompt),
"dynamic_chars": len(dynamicCtx),
"total_chars": len(fullSystemPrompt),
- "has_summary": summary != "",
+ "has_summary": req.Summary != "",
+ "overlays": len(req.Overlays),
"cached": isCached,
})
@@ -619,7 +788,7 @@ func (cb *ContextBuilder) BuildMessages(
"preview": preview,
})
- history = sanitizeHistoryForProvider(history)
+ history := sanitizeHistoryForProvider(req.History)
// Single system message containing all context — compatible with all providers.
// SystemParts enables cache-aware adapters to set per-block cache_control;
@@ -636,15 +805,8 @@ func (cb *ContextBuilder) BuildMessages(
// Add current user message. Media-only turns must still be preserved so
// multimodal providers receive the uploaded image even when the user sends
// no accompanying text.
- if strings.TrimSpace(currentMessage) != "" || len(media) > 0 {
- msg := providers.Message{
- Role: "user",
- Content: currentMessage,
- }
- if len(media) > 0 {
- msg.Media = append([]string(nil), media...)
- }
- messages = append(messages, msg)
+ if strings.TrimSpace(req.CurrentMessage) != "" || len(req.Media) > 0 {
+ messages = append(messages, userPromptMessage(req.CurrentMessage, req.Media))
}
return messages
@@ -870,6 +1032,26 @@ The following skills are active for this request. Follow them when relevant.
%s`, content)
}
+func (cb *ContextBuilder) buildActiveSkillsPromptParts(skillNames []string) []PromptPart {
+ skillsText := cb.buildActiveSkillsContext(skillNames)
+ if strings.TrimSpace(skillsText) == "" {
+ return nil
+ }
+
+ return []PromptPart{
+ {
+ ID: "capability.active_skills",
+ Layer: PromptLayerCapability,
+ Slot: PromptSlotActiveSkill,
+ Source: PromptSource{ID: PromptSourceActiveSkills, Name: "skill:active"},
+ Title: "active skills",
+ Content: skillsText,
+ Stable: false,
+ Cache: PromptCacheNone,
+ },
+ }
+}
+
func (cb *ContextBuilder) ListSkillNames() []string {
if cb.skillsLoader == nil {
return nil
diff --git a/pkg/agent/hooks.go b/pkg/agent/hooks.go
index 687e54532..9cc3e6951 100644
--- a/pkg/agent/hooks.go
+++ b/pkg/agent/hooks.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
+ "reflect"
"sort"
"sync"
"time"
@@ -325,6 +326,7 @@ func (hm *HookManager) BeforeLLM(ctx context.Context, req *LLMHookRequest) (*LLM
switch decision.normalizedAction() {
case HookActionContinue, HookActionModify:
if next != nil {
+ next = hm.applyBeforeLLMControls(reg.Name, current, next)
current = next
}
case HookActionAbortTurn, HookActionHardAbort:
@@ -367,6 +369,84 @@ func (hm *HookManager) AfterLLM(ctx context.Context, resp *LLMHookResponse) (*LL
return current, HookDecision{Action: HookActionContinue}
}
+func (hm *HookManager) applyBeforeLLMControls(
+ hookName string,
+ current *LLMHookRequest,
+ next *LLMHookRequest,
+) *LLMHookRequest {
+ if next == nil || current == nil {
+ return next
+ }
+ if !llmHookSystemMessagesUnchanged(current.Messages, next.Messages) {
+ logger.WarnCF("hooks", "Hook attempted to modify system prompt; preserving original messages", map[string]any{
+ "hook": hookName,
+ })
+ next.Messages = cloneProviderMessages(current.Messages)
+ }
+ if !llmHookToolDefinitionsUnchanged(current.Tools, next.Tools) {
+ logger.WarnCF("hooks", "Hook attempted to modify tool definitions; preserving original tools", map[string]any{
+ "hook": hookName,
+ })
+ next.Tools = cloneToolDefinitions(current.Tools)
+ }
+ return next
+}
+
+func llmHookSystemMessagesUnchanged(before, after []providers.Message) bool {
+ beforeSystem := systemMessageFingerprints(before)
+ afterSystem := systemMessageFingerprints(after)
+ return reflect.DeepEqual(beforeSystem, afterSystem)
+}
+
+type systemMessageFingerprint struct {
+ Index int
+ Message providers.Message
+}
+
+func systemMessageFingerprints(messages []providers.Message) []systemMessageFingerprint {
+ var fingerprints []systemMessageFingerprint
+ for i, msg := range messages {
+ if msg.Role != "system" {
+ continue
+ }
+ msg = providerVisibleMessage(msg)
+ fingerprints = append(fingerprints, systemMessageFingerprint{
+ Index: i,
+ Message: cloneProviderMessages([]providers.Message{msg})[0],
+ })
+ }
+ return fingerprints
+}
+
+func llmHookToolDefinitionsUnchanged(before, after []providers.ToolDefinition) bool {
+ return reflect.DeepEqual(providerVisibleToolDefinitions(before), providerVisibleToolDefinitions(after))
+}
+
+func providerVisibleMessage(msg providers.Message) providers.Message {
+ msg.PromptLayer = ""
+ msg.PromptSlot = ""
+ msg.PromptSource = ""
+ if len(msg.SystemParts) > 0 {
+ 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 = ""
+ }
+ }
+ return msg
+}
+
+func providerVisibleToolDefinitions(defs []providers.ToolDefinition) []providers.ToolDefinition {
+ cloned := cloneToolDefinitions(defs)
+ for i := range cloned {
+ cloned[i].PromptLayer = ""
+ cloned[i].PromptSlot = ""
+ cloned[i].PromptSource = ""
+ }
+ return cloned
+}
+
func (hm *HookManager) BeforeTool(
ctx context.Context,
call *ToolCallHookRequest,
@@ -788,7 +868,7 @@ func cloneLLMResponse(resp *providers.LLMResponse) *providers.LLMResponse {
func cloneStringAnyMap(src map[string]any) map[string]any {
if len(src) == 0 {
- return nil
+ return map[string]any{}
}
cloned := make(map[string]any, len(src))
diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go
index 1cfa341a7..aa52bf2d5 100644
--- a/pkg/agent/hooks_test.go
+++ b/pkg/agent/hooks_test.go
@@ -2,6 +2,7 @@ package agent
import (
"context"
+ "encoding/json"
"errors"
"os"
"strings"
@@ -149,6 +150,268 @@ func (h *llmObserverHook) AfterLLM(
return next, HookDecision{Action: HookActionModify}, nil
}
+type llmSystemRewriteHook struct{}
+
+func (h *llmSystemRewriteHook) BeforeLLM(
+ ctx context.Context,
+ req *LLMHookRequest,
+) (*LLMHookRequest, HookDecision, error) {
+ next := req.Clone()
+ next.Model = "changed-model"
+ next.Messages[0].Content = "rewritten system"
+ return next, HookDecision{Action: HookActionModify}, nil
+}
+
+func (h *llmSystemRewriteHook) AfterLLM(
+ ctx context.Context,
+ resp *LLMHookResponse,
+) (*LLMHookResponse, HookDecision, error) {
+ return resp.Clone(), HookDecision{Action: HookActionContinue}, nil
+}
+
+type llmUserAppendHook struct{}
+
+func (h *llmUserAppendHook) BeforeLLM(
+ ctx context.Context,
+ req *LLMHookRequest,
+) (*LLMHookRequest, HookDecision, error) {
+ next := req.Clone()
+ next.Messages = append(next.Messages, providers.Message{Role: "user", Content: "extra user context"})
+ return next, HookDecision{Action: HookActionModify}, nil
+}
+
+func (h *llmUserAppendHook) AfterLLM(
+ ctx context.Context,
+ resp *LLMHookResponse,
+) (*LLMHookResponse, HookDecision, error) {
+ return resp.Clone(), HookDecision{Action: HookActionContinue}, nil
+}
+
+type llmJSONRoundTripUserAppendHook struct{}
+
+type jsonRoundTripLLMHookRequest struct {
+ Model string `json:"model"`
+ Messages []providers.Message `json:"messages,omitempty"`
+ Tools []providers.ToolDefinition `json:"tools,omitempty"`
+}
+
+func (h *llmJSONRoundTripUserAppendHook) BeforeLLM(
+ ctx context.Context,
+ req *LLMHookRequest,
+) (*LLMHookRequest, HookDecision, error) {
+ payload := jsonRoundTripLLMHookRequest{
+ Model: req.Model,
+ Messages: req.Messages,
+ Tools: req.Tools,
+ }
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return nil, HookDecision{}, err
+ }
+ var decoded jsonRoundTripLLMHookRequest
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ return nil, HookDecision{}, err
+ }
+ next := req.Clone()
+ next.Model = decoded.Model
+ next.Messages = decoded.Messages
+ next.Tools = decoded.Tools
+ next.Messages = append(next.Messages, providers.Message{Role: "user", Content: "json extra user context"})
+ return next, HookDecision{Action: HookActionModify}, nil
+}
+
+func (h *llmJSONRoundTripUserAppendHook) AfterLLM(
+ ctx context.Context,
+ resp *LLMHookResponse,
+) (*LLMHookResponse, HookDecision, error) {
+ return resp.Clone(), HookDecision{Action: HookActionContinue}, nil
+}
+
+type llmToolRewriteHook struct{}
+
+func (h *llmToolRewriteHook) BeforeLLM(
+ ctx context.Context,
+ req *LLMHookRequest,
+) (*LLMHookRequest, HookDecision, error) {
+ next := req.Clone()
+ next.Model = "changed-model"
+ next.Tools[0].Function.Description = "rewritten tool"
+ next.Tools = append(next.Tools, providers.ToolDefinition{
+ Type: "function",
+ Function: providers.ToolFunctionDefinition{
+ Name: "hook_tool",
+ Description: "hook tool",
+ Parameters: map[string]any{"type": "object"},
+ },
+ PromptLayer: string(PromptLayerCapability),
+ PromptSlot: string(PromptSlotTooling),
+ PromptSource: "hook:test",
+ })
+ return next, HookDecision{Action: HookActionModify}, nil
+}
+
+func (h *llmToolRewriteHook) AfterLLM(
+ ctx context.Context,
+ resp *LLMHookResponse,
+) (*LLMHookResponse, HookDecision, error) {
+ return resp.Clone(), HookDecision{Action: HookActionContinue}, nil
+}
+
+func TestHookManager_BeforeLLMControlsSystemPromptMutation(t *testing.T) {
+ hm := NewHookManager(nil)
+ if err := hm.Mount(NamedHook("rewrite-system", &llmSystemRewriteHook{})); err != nil {
+ t.Fatalf("Mount() error = %v", err)
+ }
+
+ req := &LLMHookRequest{
+ Model: "original-model",
+ Messages: []providers.Message{
+ {
+ Role: "system",
+ Content: "original system",
+ SystemParts: []providers.ContentBlock{
+ {Type: "text", Text: "original system"},
+ },
+ },
+ {Role: "user", Content: "hello"},
+ },
+ }
+
+ got, decision := hm.BeforeLLM(context.Background(), req)
+ if decision.normalizedAction() != HookActionContinue {
+ t.Fatalf("decision = %v, want continue", decision)
+ }
+ if got.Model != "changed-model" {
+ t.Fatalf("model = %q, want changed-model", got.Model)
+ }
+ if got.Messages[0].Content != "original system" {
+ t.Fatalf("system content = %q, want original system", got.Messages[0].Content)
+ }
+ if got.Messages[1].Content != "hello" {
+ t.Fatalf("user content = %q, want hello", got.Messages[1].Content)
+ }
+}
+
+func TestHookManager_BeforeLLMAllowsNonSystemMessageMutation(t *testing.T) {
+ hm := NewHookManager(nil)
+ if err := hm.Mount(NamedHook("append-user", &llmUserAppendHook{})); err != nil {
+ t.Fatalf("Mount() error = %v", err)
+ }
+
+ req := &LLMHookRequest{
+ Model: "model",
+ Messages: []providers.Message{
+ {Role: "system", Content: "system"},
+ {Role: "user", Content: "hello"},
+ },
+ }
+
+ got, _ := hm.BeforeLLM(context.Background(), req)
+ if len(got.Messages) != 3 {
+ t.Fatalf("messages len = %d, want 3", len(got.Messages))
+ }
+ if got.Messages[2].Role != "user" || got.Messages[2].Content != "extra user context" {
+ t.Fatalf("appended message = %#v, want extra user context", got.Messages[2])
+ }
+}
+
+func TestHookManager_BeforeLLMAllowsJSONRoundTripNonSystemMessageMutation(t *testing.T) {
+ hm := NewHookManager(nil)
+ if err := hm.Mount(NamedHook("json-append-user", &llmJSONRoundTripUserAppendHook{})); err != nil {
+ t.Fatalf("Mount() error = %v", err)
+ }
+
+ req := &LLMHookRequest{
+ Model: "model",
+ Messages: []providers.Message{
+ {
+ Role: "system",
+ Content: "system",
+ PromptLayer: string(PromptLayerKernel),
+ PromptSlot: string(PromptSlotIdentity),
+ PromptSource: string(PromptSourceKernel),
+ SystemParts: []providers.ContentBlock{
+ {
+ Type: "text",
+ Text: "system",
+ CacheControl: &providers.CacheControl{Type: "ephemeral"},
+ PromptLayer: string(PromptLayerKernel),
+ PromptSlot: string(PromptSlotIdentity),
+ PromptSource: string(PromptSourceKernel),
+ },
+ },
+ },
+ {Role: "user", Content: "hello"},
+ },
+ Tools: []providers.ToolDefinition{
+ {
+ Type: "function",
+ Function: providers.ToolFunctionDefinition{
+ Name: "mcp_github_create_issue",
+ Description: "create issue",
+ Parameters: map[string]any{"type": "object"},
+ },
+ PromptLayer: string(PromptLayerCapability),
+ PromptSlot: string(PromptSlotMCP),
+ PromptSource: "mcp:github",
+ },
+ },
+ }
+
+ got, _ := hm.BeforeLLM(context.Background(), req)
+ if len(got.Messages) != 3 {
+ t.Fatalf("messages len = %d, want 3", len(got.Messages))
+ }
+ if got.Messages[2].Role != "user" || got.Messages[2].Content != "json extra user context" {
+ t.Fatalf("appended message = %#v, want json extra user context", got.Messages[2])
+ }
+}
+
+func TestHookManager_BeforeLLMControlsToolDefinitionMutation(t *testing.T) {
+ hm := NewHookManager(nil)
+ if err := hm.Mount(NamedHook("rewrite-tool", &llmToolRewriteHook{})); err != nil {
+ t.Fatalf("Mount() error = %v", err)
+ }
+
+ req := &LLMHookRequest{
+ Model: "original-model",
+ Messages: []providers.Message{
+ {Role: "system", Content: "system"},
+ {Role: "user", Content: "hello"},
+ },
+ Tools: []providers.ToolDefinition{
+ {
+ Type: "function",
+ Function: providers.ToolFunctionDefinition{
+ Name: "mcp_github_create_issue",
+ Description: "create issue",
+ Parameters: map[string]any{"type": "object"},
+ },
+ PromptLayer: string(PromptLayerCapability),
+ PromptSlot: string(PromptSlotMCP),
+ PromptSource: "mcp:github",
+ },
+ },
+ }
+
+ got, decision := hm.BeforeLLM(context.Background(), req)
+ if decision.normalizedAction() != HookActionContinue {
+ t.Fatalf("decision = %v, want continue", decision)
+ }
+ if got.Model != "changed-model" {
+ t.Fatalf("model = %q, want changed-model", got.Model)
+ }
+ if len(got.Tools) != 1 {
+ t.Fatalf("tools len = %d, want original 1", len(got.Tools))
+ }
+ if got.Tools[0].Function.Description != "create issue" {
+ t.Fatalf("tool description = %q, want original", got.Tools[0].Function.Description)
+ }
+ if got.Tools[0].PromptSource != "mcp:github" || got.Tools[0].PromptSlot != string(PromptSlotMCP) {
+ t.Fatalf("tool prompt metadata = %#v, want original mcp metadata", got.Tools[0])
+ }
+}
+
func TestAgentLoop_Hooks_ObserverAndLLMInterceptor(t *testing.T) {
provider := &llmHookTestProvider{}
al, agent, cleanup := newHookTestLoop(t, provider)
@@ -1168,6 +1431,56 @@ func TestAgentLoop_HookRespond_SteeringSkipsRemaining(t *testing.T) {
}
}
+func TestCloneStringAnyMap_EmptyMapReturnsNonNil(t *testing.T) {
+ tests := []struct {
+ name string
+ input map[string]any
+ wantNil bool
+ wantLen int
+ }{
+ {
+ name: "nil input returns empty map",
+ input: nil,
+ wantNil: false,
+ wantLen: 0,
+ },
+ {
+ name: "empty map returns empty map",
+ input: map[string]any{},
+ wantNil: false,
+ wantLen: 0,
+ },
+ {
+ name: "populated map is cloned",
+ input: map[string]any{"key": "value"},
+ wantNil: false,
+ wantLen: 1,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := cloneStringAnyMap(tt.input)
+ if result == nil {
+ t.Fatal("cloneStringAnyMap returned nil — MCP tool calls " +
+ "with no arguments would send null instead of {}")
+ }
+ if len(result) != tt.wantLen {
+ t.Fatalf("expected len %d, got %d", tt.wantLen, len(result))
+ }
+ })
+ }
+
+ t.Run("clone does not share underlying map", func(t *testing.T) {
+ src := map[string]any{"a": 1}
+ cloned := cloneStringAnyMap(src)
+ cloned["b"] = 2
+ if _, ok := src["b"]; ok {
+ t.Fatal("modifying clone should not affect source")
+ }
+ })
+}
+
func filterEvents(events []Event, kind EventKind) []Event {
var result []Event
for _, evt := range events {
diff --git a/pkg/agent/pipeline_execute.go b/pkg/agent/pipeline_execute.go
index 48e72e096..0cf3eaa9a 100644
--- a/pkg/agent/pipeline_execute.go
+++ b/pkg/agent/pipeline_execute.go
@@ -81,13 +81,18 @@ toolLoop:
)
if shouldPublishToolFeedback(al.cfg, ts) {
+ toolFeedbackMaxLen := al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength()
toolFeedbackExplanation := toolFeedbackExplanationForToolCall(
exec.response,
tc,
messages,
- al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
+ toolFeedbackMaxLen,
+ )
+ feedbackMsg := utils.FormatToolFeedbackMessage(
+ toolName,
+ toolFeedbackExplanation,
+ toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen),
)
- feedbackMsg := utils.FormatToolFeedbackMessage(toolName, toolFeedbackExplanation)
fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second)
_ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback))
fbCancel()
@@ -260,7 +265,7 @@ toolLoop:
case result, ok := <-ts.pendingResults:
if ok && result != nil && result.ForLLM != "" {
content := al.cfg.FilterSensitiveData(result.ForLLM)
- msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)}
+ msg := subTurnResultPromptMessage(content)
messages = append(messages, msg)
ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg)
}
@@ -358,13 +363,18 @@ toolLoop:
)
if shouldPublishToolFeedback(al.cfg, ts) {
+ toolFeedbackMaxLen := al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength()
toolFeedbackExplanation := toolFeedbackExplanationForToolCall(
exec.response,
tc,
messages,
- al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
+ toolFeedbackMaxLen,
+ )
+ feedbackMsg := utils.FormatToolFeedbackMessage(
+ toolName,
+ toolFeedbackExplanation,
+ toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen),
)
- feedbackMsg := utils.FormatToolFeedbackMessage(toolName, toolFeedbackExplanation)
fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second)
_ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback))
fbCancel()
@@ -631,7 +641,7 @@ toolLoop:
case result, ok := <-ts.pendingResults:
if ok && result != nil && result.ForLLM != "" {
content := al.cfg.FilterSensitiveData(result.ForLLM)
- msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)}
+ msg := subTurnResultPromptMessage(content)
messages = append(messages, msg)
ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg)
}
diff --git a/pkg/agent/pipeline_llm.go b/pkg/agent/pipeline_llm.go
index 7b3fee208..04cb98df4 100644
--- a/pkg/agent/pipeline_llm.go
+++ b/pkg/agent/pipeline_llm.go
@@ -319,10 +319,8 @@ func (p *Pipeline) CallLLM(
exec.history = asmResp.History
exec.summary = asmResp.Summary
}
- exec.messages = ts.agent.ContextBuilder.BuildMessages(
- exec.history, exec.summary, "",
- nil, ts.channel, ts.chatID, ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName,
- activeSkillNames(ts.agent, ts.opts)...,
+ exec.messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(
+ promptBuildRequestForTurn(ts, exec.history, exec.summary, "", nil),
)
exec.callMessages = exec.messages
if exec.gracefulTerminal {
diff --git a/pkg/agent/pipeline_setup.go b/pkg/agent/pipeline_setup.go
index e6ead1012..219e4e5de 100644
--- a/pkg/agent/pipeline_setup.go
+++ b/pkg/agent/pipeline_setup.go
@@ -31,16 +31,8 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
}
ts.captureRestorePoint(history, summary)
- messages := ts.agent.ContextBuilder.BuildMessages(
- history,
- summary,
- ts.userMessage,
- ts.media,
- ts.channel,
- ts.chatID,
- ts.opts.Dispatch.SenderID(),
- ts.opts.SenderDisplayName,
- activeSkillNames(ts.agent, ts.opts)...,
+ messages := ts.agent.ContextBuilder.BuildMessagesFromPrompt(
+ promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media),
)
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
@@ -69,22 +61,15 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
history = resp.History
summary = resp.Summary
}
- messages = ts.agent.ContextBuilder.BuildMessages(
- history, summary, ts.userMessage,
- ts.media, ts.channel, ts.chatID,
- ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName,
- activeSkillNames(ts.agent, ts.opts)...,
+ messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(
+ promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media),
)
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
}
}
if !ts.opts.NoHistory && (strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0) {
- rootMsg := providers.Message{
- Role: "user",
- Content: ts.userMessage,
- Media: append([]string(nil), ts.media...),
- }
+ rootMsg := userPromptMessage(ts.userMessage, ts.media)
if len(rootMsg.Media) > 0 {
ts.agent.Sessions.AddFullMessage(ts.sessionKey, rootMsg)
} else {
diff --git a/pkg/agent/prompt.go b/pkg/agent/prompt.go
new file mode 100644
index 000000000..be5ccddf2
--- /dev/null
+++ b/pkg/agent/prompt.go
@@ -0,0 +1,496 @@
+package agent
+
+import (
+ "context"
+ "fmt"
+ "slices"
+ "strings"
+ "sync"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+type PromptLayer string
+
+const (
+ PromptLayerKernel PromptLayer = "kernel"
+ PromptLayerInstruction PromptLayer = "instruction"
+ PromptLayerCapability PromptLayer = "capability"
+ PromptLayerContext PromptLayer = "context"
+ PromptLayerTurn PromptLayer = "turn"
+)
+
+type PromptSlot string
+
+const (
+ PromptSlotIdentity PromptSlot = "identity"
+ PromptSlotHierarchy PromptSlot = "hierarchy"
+ PromptSlotWorkspace PromptSlot = "workspace"
+ PromptSlotTooling PromptSlot = "tooling"
+ PromptSlotMCP PromptSlot = "mcp"
+ PromptSlotSkillCatalog PromptSlot = "skill_catalog"
+ PromptSlotActiveSkill PromptSlot = "active_skill"
+ PromptSlotMemory PromptSlot = "memory"
+ PromptSlotRuntime PromptSlot = "runtime"
+ PromptSlotSummary PromptSlot = "summary"
+ PromptSlotMessage PromptSlot = "message"
+ PromptSlotSteering PromptSlot = "steering"
+ PromptSlotSubTurn PromptSlot = "subturn"
+ PromptSlotInterrupt PromptSlot = "interrupt"
+ PromptSlotOutput PromptSlot = "output"
+)
+
+type PromptSourceID string
+
+const (
+ PromptSourceKernel PromptSourceID = "runtime.kernel"
+ PromptSourceHierarchy PromptSourceID = "runtime.hierarchy"
+ PromptSourceWorkspace PromptSourceID = "workspace.definition"
+ PromptSourceRuntime PromptSourceID = "runtime.context"
+ PromptSourceSummary PromptSourceID = "context.summary"
+ PromptSourceMemory PromptSourceID = "memory:workspace"
+ PromptSourceSkillCatalog PromptSourceID = "skill:index"
+ PromptSourceActiveSkills PromptSourceID = "skill:active"
+ PromptSourceToolRegistry PromptSourceID = "tool_registry:native"
+ PromptSourceToolDiscovery PromptSourceID = "tool_registry:discovery"
+ PromptSourceOutputPolicy PromptSourceID = "runtime.output"
+ PromptSourceSubTurnProfile PromptSourceID = "subturn.profile"
+ PromptSourceUserMessage PromptSourceID = "turn:user_message"
+ PromptSourceSteering PromptSourceID = "turn:steering"
+ PromptSourceSubTurnResult PromptSourceID = "turn:subturn_result"
+ PromptSourceInterrupt PromptSourceID = "turn:interrupt"
+)
+
+type PromptCachePolicy string
+
+const (
+ PromptCacheDefault PromptCachePolicy = ""
+ PromptCacheEphemeral PromptCachePolicy = "ephemeral"
+ PromptCacheNone PromptCachePolicy = "none"
+)
+
+type PromptPlacement struct {
+ Layer PromptLayer
+ Slot PromptSlot
+}
+
+type PromptSourceDescriptor struct {
+ ID PromptSourceID
+ Owner string
+ Description string
+ Allowed []PromptPlacement
+ StableByDefault bool
+}
+
+type PromptSource struct {
+ ID PromptSourceID
+ Name string
+ Path string
+}
+
+type PromptPart struct {
+ ID string
+ Layer PromptLayer
+ Slot PromptSlot
+ Source PromptSource
+ Title string
+ Content string
+ Stable bool
+ Cache PromptCachePolicy
+}
+
+type PromptBuildRequest struct {
+ History []providers.Message
+ Summary string
+
+ CurrentMessage string
+ Media []string
+
+ Channel string
+ ChatID string
+ SenderID string
+ SenderDisplayName string
+
+ ActiveSkills []string
+ Overlays []PromptPart
+}
+
+type PromptContributor interface {
+ PromptSource() PromptSourceDescriptor
+ ContributePrompt(ctx context.Context, req PromptBuildRequest) ([]PromptPart, error)
+}
+
+type PromptRegistry struct {
+ mu sync.RWMutex
+ sources map[PromptSourceID]PromptSourceDescriptor
+ contributors []PromptContributor
+ warned map[PromptSourceID]struct{}
+}
+
+func NewPromptRegistry() *PromptRegistry {
+ r := &PromptRegistry{
+ sources: make(map[PromptSourceID]PromptSourceDescriptor),
+ warned: make(map[PromptSourceID]struct{}),
+ }
+ for _, desc := range builtinPromptSources() {
+ if err := r.RegisterSource(desc); err != nil {
+ logger.WarnCF("agent", "Failed to register builtin prompt source", map[string]any{
+ "source": desc.ID,
+ "error": err.Error(),
+ })
+ }
+ }
+ return r
+}
+
+func builtinPromptSources() []PromptSourceDescriptor {
+ return []PromptSourceDescriptor{
+ {
+ ID: PromptSourceKernel,
+ Owner: "agent",
+ Description: "Core picoclaw identity and hard rules",
+ Allowed: []PromptPlacement{{Layer: PromptLayerKernel, Slot: PromptSlotIdentity}},
+ StableByDefault: true,
+ },
+ {
+ ID: PromptSourceHierarchy,
+ Owner: "agent",
+ Description: "Prompt hierarchy rules",
+ Allowed: []PromptPlacement{{Layer: PromptLayerKernel, Slot: PromptSlotHierarchy}},
+ StableByDefault: true,
+ },
+ {
+ ID: PromptSourceWorkspace,
+ Owner: "workspace",
+ Description: "Workspace and agent definition files",
+ Allowed: []PromptPlacement{{Layer: PromptLayerInstruction, Slot: PromptSlotWorkspace}},
+ StableByDefault: true,
+ },
+ {
+ ID: PromptSourceToolDiscovery,
+ Owner: "tools",
+ Description: "Tool discovery instructions",
+ Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}},
+ StableByDefault: true,
+ },
+ {
+ ID: PromptSourceToolRegistry,
+ Owner: "tools",
+ Description: "Native provider tool definitions",
+ Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}},
+ StableByDefault: true,
+ },
+ {
+ ID: PromptSourceSkillCatalog,
+ Owner: "skills",
+ Description: "Installed skill catalog",
+ Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotSkillCatalog}},
+ StableByDefault: true,
+ },
+ {
+ ID: PromptSourceActiveSkills,
+ Owner: "skills",
+ Description: "Active skill instructions for the current request",
+ Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotActiveSkill}},
+ StableByDefault: false,
+ },
+ {
+ ID: PromptSourceMemory,
+ Owner: "memory",
+ Description: "Workspace memory context",
+ Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotMemory}},
+ StableByDefault: true,
+ },
+ {
+ ID: PromptSourceRuntime,
+ Owner: "agent",
+ Description: "Per-request runtime context",
+ Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotRuntime}},
+ StableByDefault: false,
+ },
+ {
+ ID: PromptSourceSummary,
+ Owner: "context_manager",
+ Description: "Conversation summary context",
+ Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotSummary}},
+ StableByDefault: false,
+ },
+ {
+ ID: PromptSourceOutputPolicy,
+ Owner: "agent",
+ Description: "Output formatting policy",
+ Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotOutput}},
+ StableByDefault: true,
+ },
+ {
+ ID: PromptSourceSubTurnProfile,
+ Owner: "subturn",
+ Description: "Child agent profile instructions",
+ Allowed: []PromptPlacement{{Layer: PromptLayerInstruction, Slot: PromptSlotWorkspace}},
+ StableByDefault: false,
+ },
+ {
+ ID: PromptSourceUserMessage,
+ Owner: "turn",
+ Description: "Current user message for this turn",
+ Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotMessage}},
+ StableByDefault: false,
+ },
+ {
+ ID: PromptSourceSteering,
+ Owner: "turn",
+ Description: "Steering message injected into a running turn",
+ Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotSteering}},
+ StableByDefault: false,
+ },
+ {
+ ID: PromptSourceSubTurnResult,
+ Owner: "turn",
+ Description: "SubTurn result injected into a parent turn",
+ Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotSubTurn}},
+ StableByDefault: false,
+ },
+ {
+ ID: PromptSourceInterrupt,
+ Owner: "turn",
+ Description: "Graceful interrupt hint injected into the terminal LLM call",
+ Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotInterrupt}},
+ StableByDefault: false,
+ },
+ }
+}
+
+func (r *PromptRegistry) RegisterSource(desc PromptSourceDescriptor) error {
+ if r == nil {
+ return fmt.Errorf("prompt registry is nil")
+ }
+ desc.ID = PromptSourceID(strings.TrimSpace(string(desc.ID)))
+ if desc.ID == "" {
+ return fmt.Errorf("prompt source id is required")
+ }
+ if len(desc.Allowed) == 0 {
+ return fmt.Errorf("prompt source %q must declare at least one placement", desc.ID)
+ }
+
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.sources[desc.ID] = clonePromptSourceDescriptor(desc)
+ return nil
+}
+
+func (r *PromptRegistry) RegisterContributor(contributor PromptContributor) error {
+ if r == nil {
+ return fmt.Errorf("prompt registry is nil")
+ }
+ if contributor == nil {
+ return fmt.Errorf("prompt contributor is nil")
+ }
+ desc := contributor.PromptSource()
+ desc.ID = PromptSourceID(strings.TrimSpace(string(desc.ID)))
+ if err := r.RegisterSource(desc); err != nil {
+ return err
+ }
+
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.contributors = slices.DeleteFunc(r.contributors, func(existing PromptContributor) bool {
+ return PromptSourceID(strings.TrimSpace(string(existing.PromptSource().ID))) == desc.ID
+ })
+ r.contributors = append(r.contributors, contributor)
+ return nil
+}
+
+func (r *PromptRegistry) Collect(ctx context.Context, req PromptBuildRequest) ([]PromptPart, error) {
+ if r == nil {
+ return nil, nil
+ }
+
+ r.mu.RLock()
+ contributors := append([]PromptContributor(nil), r.contributors...)
+ r.mu.RUnlock()
+
+ var parts []PromptPart
+ for _, contributor := range contributors {
+ contributed, err := contributor.ContributePrompt(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+ for _, part := range contributed {
+ if err := r.ValidatePart(part); err != nil {
+ return nil, err
+ }
+ parts = append(parts, part)
+ }
+ }
+ return parts, nil
+}
+
+func (r *PromptRegistry) ValidatePart(part PromptPart) error {
+ if r == nil {
+ return nil
+ }
+ sourceID := PromptSourceID(strings.TrimSpace(string(part.Source.ID)))
+ if sourceID == "" {
+ return fmt.Errorf("prompt part %q has empty source id", part.ID)
+ }
+
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ desc, ok := r.sources[sourceID]
+ if !ok {
+ if _, warned := r.warned[sourceID]; !warned {
+ r.warned[sourceID] = struct{}{}
+ logger.WarnCF("agent", "Unregistered prompt source allowed in compatibility mode", map[string]any{
+ "source": sourceID,
+ "layer": part.Layer,
+ "slot": part.Slot,
+ "part": part.ID,
+ })
+ }
+ return nil
+ }
+ if promptPlacementAllowed(desc.Allowed, PromptPlacement{Layer: part.Layer, Slot: part.Slot}) {
+ return nil
+ }
+ return fmt.Errorf("prompt source %q cannot write to %s/%s", sourceID, part.Layer, part.Slot)
+}
+
+func promptPlacementAllowed(allowed []PromptPlacement, placement PromptPlacement) bool {
+ return slices.ContainsFunc(allowed, func(candidate PromptPlacement) bool {
+ return candidate.Layer == placement.Layer && candidate.Slot == placement.Slot
+ })
+}
+
+func clonePromptSourceDescriptor(desc PromptSourceDescriptor) PromptSourceDescriptor {
+ desc.Allowed = append([]PromptPlacement(nil), desc.Allowed...)
+ return desc
+}
+
+type PromptStack struct {
+ registry *PromptRegistry
+ parts []PromptPart
+ sealed bool
+}
+
+func NewPromptStack(registry *PromptRegistry) *PromptStack {
+ return &PromptStack{registry: registry}
+}
+
+func (s *PromptStack) Add(part PromptPart) error {
+ if s == nil {
+ return fmt.Errorf("prompt stack is nil")
+ }
+ if s.sealed {
+ return fmt.Errorf("prompt stack is sealed")
+ }
+ if strings.TrimSpace(part.Content) == "" {
+ return nil
+ }
+ if strings.TrimSpace(part.ID) == "" {
+ return fmt.Errorf("prompt part id is required")
+ }
+ if s.registry != nil {
+ if err := s.registry.ValidatePart(part); err != nil {
+ return err
+ }
+ }
+ s.parts = append(s.parts, part)
+ return nil
+}
+
+func (s *PromptStack) Seal() {
+ if s != nil {
+ s.sealed = true
+ }
+}
+
+func (s *PromptStack) Parts() []PromptPart {
+ if s == nil || len(s.parts) == 0 {
+ return nil
+ }
+ return append([]PromptPart(nil), s.parts...)
+}
+
+func renderPromptPartsLegacy(parts []PromptPart) string {
+ textParts := make([]string, 0, len(parts))
+ for _, part := range sortPromptParts(parts) {
+ if strings.TrimSpace(part.Content) == "" {
+ continue
+ }
+ textParts = append(textParts, part.Content)
+ }
+ return strings.Join(textParts, "\n\n---\n\n")
+}
+
+func sortPromptParts(parts []PromptPart) []PromptPart {
+ sorted := append([]PromptPart(nil), parts...)
+ slices.SortStableFunc(sorted, func(a, b PromptPart) int {
+ if d := layerPriority(b.Layer) - layerPriority(a.Layer); d != 0 {
+ return d
+ }
+ if d := slotPriority(b.Slot) - slotPriority(a.Slot); d != 0 {
+ return d
+ }
+ if a.Source.ID != b.Source.ID {
+ return strings.Compare(string(a.Source.ID), string(b.Source.ID))
+ }
+ return strings.Compare(a.ID, b.ID)
+ })
+ return sorted
+}
+
+func layerPriority(layer PromptLayer) int {
+ switch layer {
+ case PromptLayerKernel:
+ return 100
+ case PromptLayerInstruction:
+ return 80
+ case PromptLayerCapability:
+ return 60
+ case PromptLayerContext:
+ return 40
+ case PromptLayerTurn:
+ return 20
+ default:
+ return 0
+ }
+}
+
+func slotPriority(slot PromptSlot) int {
+ switch slot {
+ case PromptSlotIdentity:
+ return 1000
+ case PromptSlotHierarchy:
+ return 990
+ case PromptSlotWorkspace:
+ return 900
+ case PromptSlotTooling:
+ return 800
+ case PromptSlotMCP:
+ return 790
+ case PromptSlotSkillCatalog:
+ return 780
+ case PromptSlotActiveSkill:
+ return 770
+ case PromptSlotMemory:
+ return 700
+ case PromptSlotOutput:
+ return 695
+ case PromptSlotRuntime:
+ return 690
+ case PromptSlotSummary:
+ return 680
+ case PromptSlotMessage:
+ return 600
+ case PromptSlotSteering:
+ return 590
+ case PromptSlotSubTurn:
+ return 580
+ case PromptSlotInterrupt:
+ return 570
+ default:
+ return 0
+ }
+}
diff --git a/pkg/agent/prompt_contributors.go b/pkg/agent/prompt_contributors.go
new file mode 100644
index 000000000..960572e03
--- /dev/null
+++ b/pkg/agent/prompt_contributors.go
@@ -0,0 +1,139 @@
+package agent
+
+import (
+ "context"
+ "fmt"
+ "strings"
+)
+
+type toolDiscoveryPromptContributor struct {
+ useBM25 bool
+ useRegex bool
+}
+
+func (c toolDiscoveryPromptContributor) PromptSource() PromptSourceDescriptor {
+ return PromptSourceDescriptor{
+ ID: PromptSourceToolDiscovery,
+ Owner: "tools",
+ Description: "Tool discovery instructions",
+ Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}},
+ StableByDefault: true,
+ }
+}
+
+func (c toolDiscoveryPromptContributor) ContributePrompt(
+ _ context.Context,
+ _ PromptBuildRequest,
+) ([]PromptPart, error) {
+ content := formatToolDiscoveryRule(c.useBM25, c.useRegex)
+ if strings.TrimSpace(content) == "" {
+ return nil, nil
+ }
+
+ return []PromptPart{
+ {
+ ID: "capability.tool_discovery",
+ Layer: PromptLayerCapability,
+ Slot: PromptSlotTooling,
+ Source: PromptSource{ID: PromptSourceToolDiscovery, Name: "tool_registry:discovery"},
+ Title: "tool discovery",
+ Content: content,
+ Stable: true,
+ Cache: PromptCacheEphemeral,
+ },
+ }, nil
+}
+
+type mcpServerPromptContributor struct {
+ serverName string
+ toolCount int
+ deferred bool
+}
+
+func (c mcpServerPromptContributor) PromptSource() PromptSourceDescriptor {
+ return PromptSourceDescriptor{
+ ID: mcpPromptSourceID(c.serverName),
+ Owner: "mcp",
+ Description: fmt.Sprintf("MCP server %q capability prompt", c.serverName),
+ Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotMCP}},
+ StableByDefault: true,
+ }
+}
+
+func (c mcpServerPromptContributor) ContributePrompt(
+ _ context.Context,
+ _ PromptBuildRequest,
+) ([]PromptPart, error) {
+ serverName := strings.TrimSpace(c.serverName)
+ if serverName == "" || c.toolCount <= 0 {
+ return nil, nil
+ }
+
+ availability := "available as native tools"
+ if c.deferred {
+ availability = "hidden behind tool discovery until unlocked"
+ }
+
+ return []PromptPart{
+ {
+ ID: "capability.mcp." + promptSourceComponent(serverName),
+ Layer: PromptLayerCapability,
+ Slot: PromptSlotMCP,
+ Source: PromptSource{ID: mcpPromptSourceID(serverName), Name: "mcp:" + serverName},
+ Title: "MCP server capability",
+ Content: fmt.Sprintf(
+ "MCP server `%s` is connected. It contributes %d tool(s), currently %s.",
+ serverName,
+ c.toolCount,
+ availability,
+ ),
+ Stable: true,
+ Cache: PromptCacheEphemeral,
+ },
+ }, nil
+}
+
+func mcpPromptSourceID(serverName string) PromptSourceID {
+ return PromptSourceID("mcp:" + promptSourceComponent(serverName))
+}
+
+func promptSourceComponent(value string) string {
+ const maxLen = 64
+
+ value = strings.ToLower(strings.TrimSpace(value))
+ if value == "" {
+ return "unnamed"
+ }
+
+ var b strings.Builder
+ lastWasSep := false
+ for _, r := range value {
+ switch {
+ case r >= 'a' && r <= 'z':
+ b.WriteRune(r)
+ lastWasSep = false
+ case r >= '0' && r <= '9':
+ b.WriteRune(r)
+ lastWasSep = false
+ case r == '-' || r == '_':
+ if !lastWasSep && b.Len() > 0 {
+ b.WriteRune(r)
+ lastWasSep = true
+ }
+ default:
+ if !lastWasSep && b.Len() > 0 {
+ b.WriteRune('_')
+ lastWasSep = true
+ }
+ }
+ }
+
+ result := strings.Trim(b.String(), "_")
+ if result == "" {
+ return "unnamed"
+ }
+ if len(result) > maxLen {
+ return result[:maxLen]
+ }
+ return result
+}
diff --git a/pkg/agent/prompt_test.go b/pkg/agent/prompt_test.go
new file mode 100644
index 000000000..b76b0040d
--- /dev/null
+++ b/pkg/agent/prompt_test.go
@@ -0,0 +1,275 @@
+package agent
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+func TestPromptRegistry_RejectsRegisteredSourceWrongPlacement(t *testing.T) {
+ registry := NewPromptRegistry()
+ if err := registry.RegisterSource(PromptSourceDescriptor{
+ ID: "test:source",
+ Owner: "test",
+ Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}},
+ }); err != nil {
+ t.Fatalf("RegisterSource() error = %v", err)
+ }
+
+ err := registry.ValidatePart(PromptPart{
+ ID: "wrong.placement",
+ Layer: PromptLayerContext,
+ Slot: PromptSlotRuntime,
+ Source: PromptSource{ID: "test:source"},
+ Content: "runtime text",
+ })
+ if err == nil {
+ t.Fatal("ValidatePart() error = nil, want placement error")
+ }
+}
+
+func TestPromptRegistry_AllowsUnregisteredSourceInCompatibilityMode(t *testing.T) {
+ registry := NewPromptRegistry()
+
+ err := registry.ValidatePart(PromptPart{
+ ID: "unregistered.part",
+ Layer: PromptLayerCapability,
+ Slot: PromptSlotMCP,
+ Source: PromptSource{ID: "mcp:dynamic-server"},
+ Content: "dynamic MCP prompt",
+ })
+ if err != nil {
+ t.Fatalf("ValidatePart() error = %v, want nil for unregistered source", err)
+ }
+}
+
+func TestRenderPromptPartsLegacy_UsesLayerAndSlotOrder(t *testing.T) {
+ parts := []PromptPart{
+ {
+ ID: "context.runtime",
+ Layer: PromptLayerContext,
+ Slot: PromptSlotRuntime,
+ Source: PromptSource{ID: PromptSourceRuntime},
+ Content: "runtime",
+ },
+ {
+ ID: "kernel.identity",
+ Layer: PromptLayerKernel,
+ Slot: PromptSlotIdentity,
+ Source: PromptSource{ID: PromptSourceKernel},
+ Content: "kernel",
+ },
+ {
+ ID: "capability.skill",
+ Layer: PromptLayerCapability,
+ Slot: PromptSlotActiveSkill,
+ Source: PromptSource{ID: "skill:test"},
+ Content: "skill",
+ },
+ {
+ ID: "instruction.workspace",
+ Layer: PromptLayerInstruction,
+ Slot: PromptSlotWorkspace,
+ Source: PromptSource{ID: PromptSourceWorkspace},
+ Content: "workspace",
+ },
+ }
+
+ got := renderPromptPartsLegacy(parts)
+ want := strings.Join([]string{"kernel", "workspace", "skill", "runtime"}, "\n\n---\n\n")
+ if got != want {
+ t.Fatalf("renderPromptPartsLegacy() = %q, want %q", got, want)
+ }
+}
+
+func TestBuildMessagesFromPrompt_IncludesSystemPromptOverlay(t *testing.T) {
+ t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir())
+ cb := NewContextBuilder(t.TempDir())
+
+ messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{
+ CurrentMessage: "do child task",
+ Overlays: promptOverlaysForOptions(processOptions{
+ SystemPromptOverride: "Use child-only system instructions.",
+ }),
+ })
+
+ if len(messages) < 2 {
+ t.Fatalf("messages len = %d, want at least 2", len(messages))
+ }
+ if messages[0].Role != "system" {
+ t.Fatalf("messages[0].Role = %q, want system", messages[0].Role)
+ }
+ if !strings.Contains(messages[0].Content, "Use child-only system instructions.") {
+ t.Fatalf("system prompt missing overlay: %q", messages[0].Content)
+ }
+ if messages[1].Role != "user" || messages[1].Content != "do child task" {
+ t.Fatalf("messages[1] = %#v, want user task", messages[1])
+ }
+}
+
+func TestBuildMessagesFromPrompt_AttachesInternalPromptMetadata(t *testing.T) {
+ t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir())
+ cb := NewContextBuilder(t.TempDir())
+
+ messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{
+ CurrentMessage: "hello",
+ Summary: "prior context",
+ })
+ if len(messages) != 2 {
+ t.Fatalf("messages len = %d, want 2", len(messages))
+ }
+
+ system := messages[0]
+ if len(system.SystemParts) < 3 {
+ t.Fatalf("system parts len = %d, want at least 3", len(system.SystemParts))
+ }
+ if system.SystemParts[0].PromptLayer != string(PromptLayerKernel) ||
+ system.SystemParts[0].PromptSlot != string(PromptSlotIdentity) ||
+ system.SystemParts[0].PromptSource != string(PromptSourceKernel) {
+ t.Fatalf("static system metadata = %#v, want kernel identity", system.SystemParts[0])
+ }
+
+ var hasRuntime, hasSummary bool
+ for _, part := range system.SystemParts {
+ switch part.PromptSource {
+ case string(PromptSourceRuntime):
+ hasRuntime = true
+ if part.CacheControl != nil {
+ t.Fatalf("runtime cache control = %#v, want nil", part.CacheControl)
+ }
+ case string(PromptSourceSummary):
+ hasSummary = true
+ if part.CacheControl != nil {
+ t.Fatalf("summary cache control = %#v, want nil", part.CacheControl)
+ }
+ }
+ }
+ if !hasRuntime {
+ t.Fatal("system parts missing runtime prompt metadata")
+ }
+ if !hasSummary {
+ t.Fatal("system parts missing summary prompt metadata")
+ }
+
+ user := messages[1]
+ if user.PromptLayer != string(PromptLayerTurn) ||
+ user.PromptSlot != string(PromptSlotMessage) ||
+ user.PromptSource != string(PromptSourceUserMessage) {
+ t.Fatalf("user message metadata = %#v, want turn message", user)
+ }
+
+ data, err := json.Marshal(messages)
+ if err != nil {
+ t.Fatalf("json.Marshal() error = %v", err)
+ }
+ if strings.Contains(string(data), "PromptSource") ||
+ strings.Contains(string(data), "PromptLayer") ||
+ strings.Contains(string(data), "PromptSlot") {
+ t.Fatalf("internal prompt metadata leaked into JSON: %s", data)
+ }
+}
+
+func TestContextBuilder_CollectsToolDiscoveryContributor(t *testing.T) {
+ t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir())
+ cb := NewContextBuilder(t.TempDir()).WithToolDiscovery(true, false)
+
+ messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"})
+ system := messages[0]
+ if !strings.Contains(system.Content, "tool_search_tool_bm25") {
+ t.Fatalf("system prompt missing tool discovery rule: %q", system.Content)
+ }
+
+ var found bool
+ for _, part := range system.SystemParts {
+ if part.PromptSource == string(PromptSourceToolDiscovery) {
+ found = true
+ if part.PromptLayer != string(PromptLayerCapability) || part.PromptSlot != string(PromptSlotTooling) {
+ t.Fatalf("tool discovery metadata = %#v, want capability/tooling", part)
+ }
+ if part.CacheControl == nil || part.CacheControl.Type != "ephemeral" {
+ t.Fatalf("tool discovery cache control = %#v, want ephemeral", part.CacheControl)
+ }
+ }
+ }
+ if !found {
+ t.Fatal("system parts missing tool discovery prompt metadata")
+ }
+}
+
+func TestContextBuilder_CollectsMCPServerContributor(t *testing.T) {
+ t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir())
+ cb := NewContextBuilder(t.TempDir())
+ err := cb.RegisterPromptContributor(mcpServerPromptContributor{
+ serverName: "GitHub Server",
+ toolCount: 3,
+ deferred: true,
+ })
+ if err != nil {
+ t.Fatalf("RegisterPromptContributor() error = %v", err)
+ }
+
+ messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"})
+ system := messages[0]
+ if !strings.Contains(system.Content, "MCP server `GitHub Server` is connected") {
+ t.Fatalf("system prompt missing MCP contributor content: %q", system.Content)
+ }
+
+ var found bool
+ for _, part := range system.SystemParts {
+ if part.PromptSource == "mcp:github_server" {
+ found = true
+ if part.PromptLayer != string(PromptLayerCapability) || part.PromptSlot != string(PromptSlotMCP) {
+ t.Fatalf("mcp metadata = %#v, want capability/mcp", part)
+ }
+ if part.CacheControl == nil || part.CacheControl.Type != "ephemeral" {
+ t.Fatalf("mcp cache control = %#v, want ephemeral", part.CacheControl)
+ }
+ }
+ }
+ if !found {
+ t.Fatal("system parts missing MCP prompt metadata")
+ }
+}
+
+type testPromptContributor struct {
+ desc PromptSourceDescriptor
+ part PromptPart
+}
+
+func (c testPromptContributor) PromptSource() PromptSourceDescriptor {
+ return c.desc
+}
+
+func (c testPromptContributor) ContributePrompt(_ context.Context, _ PromptBuildRequest) ([]PromptPart, error) {
+ return []PromptPart{c.part}, nil
+}
+
+func TestContextBuilder_CollectsRegisteredPromptContributors(t *testing.T) {
+ t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir())
+ cb := NewContextBuilder(t.TempDir())
+
+ sourceID := PromptSourceID("test:contributor")
+ err := cb.RegisterPromptContributor(testPromptContributor{
+ desc: PromptSourceDescriptor{
+ ID: sourceID,
+ Owner: "test",
+ Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotMCP}},
+ },
+ part: PromptPart{
+ ID: "capability.mcp.test",
+ Layer: PromptLayerCapability,
+ Slot: PromptSlotMCP,
+ Source: PromptSource{ID: sourceID, Name: "test"},
+ Content: "registered contributor prompt",
+ },
+ })
+ if err != nil {
+ t.Fatalf("RegisterPromptContributor() error = %v", err)
+ }
+
+ messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"})
+ if !strings.Contains(messages[0].Content, "registered contributor prompt") {
+ t.Fatalf("system prompt missing contributor content: %q", messages[0].Content)
+ }
+}
diff --git a/pkg/agent/prompt_turn.go b/pkg/agent/prompt_turn.go
new file mode 100644
index 000000000..588a8f00f
--- /dev/null
+++ b/pkg/agent/prompt_turn.go
@@ -0,0 +1,129 @@
+package agent
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+func promptBuildRequestForTurn(
+ ts *turnState,
+ history []providers.Message,
+ summary string,
+ currentMessage string,
+ media []string,
+) PromptBuildRequest {
+ return PromptBuildRequest{
+ History: history,
+ Summary: summary,
+ CurrentMessage: currentMessage,
+ Media: append([]string(nil), media...),
+ Channel: ts.channel,
+ ChatID: ts.chatID,
+ SenderID: ts.opts.Dispatch.SenderID(),
+ SenderDisplayName: ts.opts.SenderDisplayName,
+ ActiveSkills: activeSkillNames(ts.agent, ts.opts),
+ Overlays: promptOverlaysForOptions(ts.opts),
+ }
+}
+
+func promptOverlaysForOptions(opts processOptions) []PromptPart {
+ systemPrompt := strings.TrimSpace(opts.SystemPromptOverride)
+ if systemPrompt == "" {
+ return nil
+ }
+
+ return []PromptPart{
+ {
+ ID: "instruction.subturn_profile",
+ Layer: PromptLayerInstruction,
+ Slot: PromptSlotWorkspace,
+ Source: PromptSource{ID: PromptSourceSubTurnProfile, Name: "subturn.profile"},
+ Title: "SubTurn System Instructions",
+ Content: systemPrompt,
+ Stable: false,
+ Cache: PromptCacheNone,
+ },
+ }
+}
+
+func promptContentBlock(part PromptPart, cache *providers.CacheControl) providers.ContentBlock {
+ if cache == nil {
+ cache = cacheControlForPromptPart(part)
+ }
+ return providers.ContentBlock{
+ Type: "text",
+ Text: part.Content,
+ CacheControl: cache,
+ PromptLayer: string(part.Layer),
+ PromptSlot: string(part.Slot),
+ PromptSource: string(part.Source.ID),
+ }
+}
+
+func cacheControlForPromptPart(part PromptPart) *providers.CacheControl {
+ switch part.Cache {
+ case PromptCacheEphemeral:
+ return &providers.CacheControl{Type: "ephemeral"}
+ default:
+ return nil
+ }
+}
+
+func promptMessageWithMetadata(
+ msg providers.Message,
+ layer PromptLayer,
+ slot PromptSlot,
+ source PromptSourceID,
+) providers.Message {
+ msg.PromptLayer = string(layer)
+ msg.PromptSlot = string(slot)
+ msg.PromptSource = string(source)
+ return msg
+}
+
+func promptMessageWithDefaultMetadata(
+ msg providers.Message,
+ layer PromptLayer,
+ slot PromptSlot,
+ source PromptSourceID,
+) providers.Message {
+ if strings.TrimSpace(msg.PromptSource) != "" {
+ return msg
+ }
+ return promptMessageWithMetadata(msg, layer, slot, source)
+}
+
+func userPromptMessage(content string, media []string) providers.Message {
+ msg := providers.Message{
+ Role: "user",
+ Content: content,
+ }
+ if len(media) > 0 {
+ msg.Media = append([]string(nil), media...)
+ }
+ return promptMessageWithMetadata(msg, PromptLayerTurn, PromptSlotMessage, PromptSourceUserMessage)
+}
+
+func steeringPromptMessage(msg providers.Message) providers.Message {
+ return promptMessageWithDefaultMetadata(msg, PromptLayerTurn, PromptSlotSteering, PromptSourceSteering)
+}
+
+func subTurnResultPromptMessage(content string) providers.Message {
+ return promptMessageWithMetadata(
+ providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)},
+ PromptLayerTurn,
+ PromptSlotSubTurn,
+ PromptSourceSubTurnResult,
+ )
+}
+
+func interruptPromptMessage(content string) providers.Message {
+ return promptMessageWithMetadata(
+ providers.Message{Role: "user", Content: content},
+ PromptLayerTurn,
+ PromptSlotInterrupt,
+ PromptSourceInterrupt,
+ )
+}
diff --git a/pkg/agent/steering.go b/pkg/agent/steering.go
index bff01fbf8..2efa7bbf4 100644
--- a/pkg/agent/steering.go
+++ b/pkg/agent/steering.go
@@ -187,6 +187,7 @@ func (al *AgentLoop) enqueueSteeringMessage(scope, agentID string, msg providers
return fmt.Errorf("steering queue is not initialized")
}
+ msg = steeringPromptMessage(msg)
if err := al.steering.pushScope(scope, msg); err != nil {
logger.WarnCF("agent", "Failed to enqueue steering message", map[string]any{
"error": err.Error(),
diff --git a/pkg/agent/turn_coord.go b/pkg/agent/turn_coord.go
index 4c8335933..ade2b7c21 100644
--- a/pkg/agent/turn_coord.go
+++ b/pkg/agent/turn_coord.go
@@ -111,7 +111,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
case result, ok := <-ts.pendingResults:
if ok && result != nil && result.ForLLM != "" {
content := al.cfg.FilterSensitiveData(result.ForLLM)
- msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)}
+ msg := subTurnResultPromptMessage(content)
pendingMessages = append(pendingMessages, msg)
}
default:
diff --git a/pkg/agent/turn_state.go b/pkg/agent/turn_state.go
index 8b5fd4e2c..360c3b7d5 100644
--- a/pkg/agent/turn_state.go
+++ b/pkg/agent/turn_state.go
@@ -527,10 +527,7 @@ func (ts *turnState) interruptHintMessage() providers.Message {
if hint != "" {
content += "\n\nInterrupt hint: " + hint
}
- return providers.Message{
- Role: "user",
- Content: content,
- }
+ return interruptPromptMessage(content)
}
// =============================================================================
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index 2ffb1bb10..7974a39e4 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -170,6 +170,20 @@ func dismissTrackedToolFeedbackMessage(
}
}
+func clearTrackedToolFeedbackMessage(
+ ch Channel,
+ chatID string,
+ outboundCtx *bus.InboundContext,
+) {
+ trackedChatID := trackedToolFeedbackMessageChatID(ch, chatID, outboundCtx)
+ if trackedChatID == "" {
+ return
+ }
+ if tracker, ok := ch.(toolFeedbackMessageTracker); ok {
+ tracker.ClearToolFeedbackMessage(trackedChatID)
+ }
+}
+
func prepareToolFeedbackMessageContent(ch Channel, content string) string {
prepared := strings.TrimSpace(content)
if prepared == "" {
@@ -183,6 +197,13 @@ func prepareToolFeedbackMessageContent(ch Channel, content string) string {
return prepared
}
+func (m *Manager) toolFeedbackSeparateMessagesEnabled() bool {
+ if m == nil || m.config == nil {
+ return false
+ }
+ return m.config.Agents.Defaults.IsToolFeedbackSeparateMessagesEnabled()
+}
+
// RecordPlaceholder registers a placeholder message for later editing.
// Implements PlaceholderRecorder.
func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) {
@@ -264,6 +285,7 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
}
isToolFeedback := outboundMessageIsToolFeedback(msg)
+ separateToolFeedbackMessages := m.toolFeedbackSeparateMessagesEnabled()
// 3. If a stream already finalized this chat, stale tool feedback must be
// dropped without consuming the final-response marker. Streaming finalization
@@ -288,14 +310,28 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
}
}
if !isToolFeedback {
- dismissTrackedToolFeedbackMessage(ctx, ch, chatID, &msg.Context)
+ if separateToolFeedbackMessages {
+ clearTrackedToolFeedbackMessage(ch, chatID, &msg.Context)
+ } else {
+ dismissTrackedToolFeedbackMessage(ctx, ch, chatID, &msg.Context)
+ }
}
return nil, true
}
+ if separateToolFeedbackMessages {
+ clearTrackedToolFeedbackMessage(ch, chatID, &msg.Context)
+ }
+
// 5. Try editing placeholder
if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
+ if isToolFeedback && separateToolFeedbackMessages {
+ if deleter, ok := ch.(MessageDeleter); ok {
+ deleter.DeleteMessage(ctx, chatID, entry.id) // best effort
+ }
+ return nil, false
+ }
if editor, ok := ch.(MessageEditor); ok {
content := msg.Content
trackedContent := msg.Content
@@ -345,6 +381,10 @@ func (m *Manager) preSendMedia(ctx context.Context, name string, msg bus.Outboun
// 3. Clear any finalized stream marker for this chat before media delivery.
m.streamActive.LoadAndDelete(key)
+ if m.toolFeedbackSeparateMessagesEnabled() {
+ clearTrackedToolFeedbackMessage(ch, chatID, &msg.Context)
+ }
+
// 4. Delete placeholder if present.
if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
@@ -408,15 +448,26 @@ func (m *Manager) GetStreamer(ctx context.Context, channelName, chatID string) (
return &finalizeHookStreamer{
Streamer: streamer,
onFinalize: func(finalizeCtx context.Context) {
- dismissTrackedToolFeedbackMessage(
- finalizeCtx,
- ch,
- chatID,
- &bus.InboundContext{
- Channel: channelName,
- ChatID: chatID,
- },
- )
+ if m.toolFeedbackSeparateMessagesEnabled() {
+ clearTrackedToolFeedbackMessage(
+ ch,
+ chatID,
+ &bus.InboundContext{
+ Channel: channelName,
+ ChatID: chatID,
+ },
+ )
+ } else {
+ dismissTrackedToolFeedbackMessage(
+ finalizeCtx,
+ ch,
+ chatID,
+ &bus.InboundContext{
+ Channel: channelName,
+ ChatID: chatID,
+ },
+ )
+ }
m.streamActive.Store(key, true)
},
}, true
diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go
index 273c90468..a5d7c2838 100644
--- a/pkg/channels/manager_test.go
+++ b/pkg/channels/manager_test.go
@@ -804,6 +804,20 @@ type mockResolvedToolFeedbackEditor struct {
resolveChatIDFn func(chatID string, outboundCtx *bus.InboundContext) string
}
+type mockDeletingMessageEditor struct {
+ mockMessageEditor
+ deleteCalls int
+ deletedChatID string
+ deletedMessageID string
+}
+
+func (m *mockDeletingMessageEditor) DeleteMessage(_ context.Context, chatID, messageID string) error {
+ m.deleteCalls++
+ m.deletedChatID = chatID
+ m.deletedMessageID = messageID
+ return nil
+}
+
func (m *mockResolvedToolFeedbackEditor) ToolFeedbackMessageChatID(
chatID string,
outboundCtx *bus.InboundContext,
@@ -1062,6 +1076,101 @@ func TestPreSend_NonToolFeedbackDefersTrackedMessageFinalizationToChannelSend(t
}
}
+func TestPreSend_ToolFeedbackSeparateMessagesDeletesPlaceholderAndSkipsEdit(t *testing.T) {
+ m := newTestManager()
+ m.config = &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ ToolFeedback: config.ToolFeedbackConfig{
+ Enabled: true,
+ SeparateMessages: true,
+ },
+ },
+ },
+ }
+
+ ch := &mockDeletingMessageEditor{
+ mockMessageEditor: mockMessageEditor{
+ editFn: func(_ context.Context, _, _, _ string) error {
+ t.Fatal("expected placeholder edit to be skipped in separate message mode")
+ return nil
+ },
+ },
+ }
+
+ m.RecordPlaceholder("test", "123", "456")
+
+ msg := testOutboundMessage(bus.OutboundMessage{
+ Channel: "test",
+ ChatID: "123",
+ Content: "hello",
+ Context: bus.InboundContext{
+ Channel: "test",
+ ChatID: "123",
+ Raw: map[string]string{
+ "message_kind": "tool_feedback",
+ },
+ },
+ })
+
+ msgIDs, handled := m.preSend(context.Background(), "test", msg, ch)
+ if handled {
+ t.Fatalf("expected preSend to fall through so the channel can send a new message, got %v", msgIDs)
+ }
+ if ch.deleteCalls != 1 {
+ t.Fatalf("expected placeholder deletion, got %d delete calls", ch.deleteCalls)
+ }
+ if ch.deletedChatID != "123" || ch.deletedMessageID != "456" {
+ t.Fatalf("unexpected placeholder deletion target: %s/%s", ch.deletedChatID, ch.deletedMessageID)
+ }
+ if ch.recordedMessageID != "" {
+ t.Fatalf("expected no tracked placeholder record, got %q", ch.recordedMessageID)
+ }
+ if ch.clearedChatID != "123" {
+ t.Fatalf("expected tracked tool feedback state to be cleared before sending, got %q", ch.clearedChatID)
+ }
+}
+
+func TestPreSend_NonToolFeedbackSeparateMessagesClearsTrackedMessageWithoutDismiss(t *testing.T) {
+ m := newTestManager()
+ m.config = &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ ToolFeedback: config.ToolFeedbackConfig{
+ Enabled: true,
+ SeparateMessages: true,
+ },
+ },
+ },
+ }
+
+ ch := &mockMessageEditor{}
+
+ msg := testOutboundMessage(bus.OutboundMessage{
+ Channel: "test",
+ ChatID: "123",
+ Content: "final reply",
+ Context: bus.InboundContext{
+ Channel: "test",
+ ChatID: "123",
+ },
+ })
+
+ _, handled := m.preSend(context.Background(), "test", msg, ch)
+ if handled {
+ t.Fatal("expected preSend to leave final delivery to the channel")
+ }
+ if ch.clearedChatID != "123" {
+ t.Fatalf("expected tracked tool feedback state to be cleared, got %q", ch.clearedChatID)
+ }
+ if ch.dismissedChatID != "" {
+ t.Fatalf("expected tracked tool feedback message to be preserved, got dismissal for %q", ch.dismissedChatID)
+ }
+ if ch.finalizeCalled {
+ t.Fatal("expected separate message mode to skip in-place finalization")
+ }
+}
+
func TestPreSend_StaleToolFeedbackDoesNotConsumeStreamActiveMarker(t *testing.T) {
m := newTestManager()
m.streamActive.Store("test:123", true)
@@ -1153,6 +1262,38 @@ func TestPreSendMedia_LeavesTrackedMessageForChannelSend(t *testing.T) {
}
}
+func TestPreSendMedia_SeparateMessagesClearsTrackedMessageWithoutDismiss(t *testing.T) {
+ m := newTestManager()
+ m.config = &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ ToolFeedback: config.ToolFeedbackConfig{
+ Enabled: true,
+ SeparateMessages: true,
+ },
+ },
+ },
+ }
+
+ ch := &mockMessageEditor{}
+
+ m.preSendMedia(context.Background(), "test", bus.OutboundMediaMessage{
+ ChatID: "123",
+ Context: bus.InboundContext{
+ Channel: "test",
+ ChatID: "123",
+ },
+ }, ch)
+
+ if ch.clearedChatID != "123" {
+ t.Fatalf("expected tracked tool feedback state to be cleared before media delivery, got %q", ch.clearedChatID)
+ }
+ if ch.dismissedChatID != "" {
+ t.Fatalf("expected tracked tool feedback message to be preserved"+
+ " for media delivery, got %q", ch.dismissedChatID)
+ }
+}
+
func TestSplitOutboundMessageContent_ToolFeedbackTruncatesInsteadOfSplitting(t *testing.T) {
msg := testOutboundMessage(bus.OutboundMessage{
Channel: "test",
@@ -1232,6 +1373,49 @@ func TestGetStreamer_FinalizeDismissesTrackedToolFeedback(t *testing.T) {
}
}
+func TestGetStreamer_FinalizeSeparateMessagesClearsTrackedToolFeedback(t *testing.T) {
+ m := newTestManager()
+ m.config = &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ ToolFeedback: config.ToolFeedbackConfig{
+ Enabled: true,
+ SeparateMessages: true,
+ },
+ },
+ },
+ }
+ ch := &mockStreamingChannel{
+ mockMessageEditor: mockMessageEditor{},
+ streamer: &mockStreamer{
+ finalizeFn: func(_ context.Context, content string) error {
+ if content != "final reply" {
+ t.Fatalf("unexpected finalize content: %q", content)
+ }
+ return nil
+ },
+ },
+ }
+ m.channels["test"] = ch
+
+ streamer, ok := m.GetStreamer(context.Background(), "test", "123")
+ if !ok {
+ t.Fatal("expected streamer to be available")
+ }
+ if err := streamer.Finalize(context.Background(), "final reply"); err != nil {
+ t.Fatalf("Finalize() error = %v", err)
+ }
+ if ch.clearedChatID != "123" {
+ t.Fatalf("expected tracked tool feedback to be cleared for chat 123, got %q", ch.clearedChatID)
+ }
+ if ch.dismissedChatID != "" {
+ t.Fatalf("expected tracked tool feedback message to be preserved, got dismissal for %q", ch.dismissedChatID)
+ }
+ if _, ok := m.streamActive.Load("test:123"); !ok {
+ t.Fatal("expected streamActive marker to be recorded after finalize")
+ }
+}
+
func TestGetStreamer_FinalizeDismissesResolvedTrackedToolFeedback(t *testing.T) {
m := newTestManager()
ch := &mockStreamingChannel{
diff --git a/pkg/channels/telegram/parser_markdown_to_html.go b/pkg/channels/telegram/parser_markdown_to_html.go
index 95dc3e9d6..0614b6e32 100644
--- a/pkg/channels/telegram/parser_markdown_to_html.go
+++ b/pkg/channels/telegram/parser_markdown_to_html.go
@@ -2,9 +2,13 @@ package telegram
import (
"fmt"
+ "html"
+ "regexp"
"strings"
)
+var reRawURL = regexp.MustCompile(`https?://[^\s<]+`)
+
func markdownToTelegramHTML(text string) string {
if text == "" {
return ""
@@ -19,6 +23,9 @@ func markdownToTelegramHTML(text string) string {
links := extractLinks(text)
text = links.text
+ rawURLs := extractRawURLs(text)
+ text = rawURLs.text
+
text = reHeading.ReplaceAllString(text, "$1")
text = reBlockquote.ReplaceAllString(text, "$1")
@@ -43,10 +50,19 @@ func markdownToTelegramHTML(text string) string {
for i, lnk := range links.links {
label := escapeHTML(lnk[0])
- url := lnk[1]
+ url := escapeHTMLAttr(lnk[1])
text = strings.ReplaceAll(text, fmt.Sprintf("\x00LK%d\x00", i), fmt.Sprintf(`%s`, url, label))
}
+ for i, rawURL := range rawURLs.urls {
+ escaped := escapeHTML(rawURL)
+ text = strings.ReplaceAll(
+ text,
+ fmt.Sprintf("\x00RU%d\x00", i),
+ fmt.Sprintf(`%s`, escapeHTMLAttr(rawURL), escaped),
+ )
+ }
+
for i, code := range inlineCodes.codes {
escaped := escapeHTML(code)
text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("%s", escaped))
@@ -92,6 +108,11 @@ type codeBlockMatch struct {
codes []string
}
+type rawURLMatch struct {
+ text string
+ urls []string
+}
+
func extractCodeBlocks(text string) codeBlockMatch {
matches := reCodeBlock.FindAllStringSubmatch(text, -1)
@@ -110,6 +131,24 @@ func extractCodeBlocks(text string) codeBlockMatch {
return codeBlockMatch{text: text, codes: codes}
}
+func extractRawURLs(text string) rawURLMatch {
+ matches := reRawURL.FindAllString(text, -1)
+
+ urls := make([]string, 0, len(matches))
+ for _, match := range matches {
+ urls = append(urls, match)
+ }
+
+ i := 0
+ text = reRawURL.ReplaceAllStringFunc(text, func(string) string {
+ placeholder := fmt.Sprintf("\x00RU%d\x00", i)
+ i++
+ return placeholder
+ })
+
+ return rawURLMatch{text: text, urls: urls}
+}
+
type inlineCodeMatch struct {
text string
codes []string
@@ -139,3 +178,7 @@ func escapeHTML(text string) string {
text = strings.ReplaceAll(text, ">", ">")
return text
}
+
+func escapeHTMLAttr(text string) string {
+ return html.EscapeString(text)
+}
diff --git a/pkg/channels/telegram/parser_markdown_to_html_test.go b/pkg/channels/telegram/parser_markdown_to_html_test.go
index 7754ee076..a54a1c2c7 100644
--- a/pkg/channels/telegram/parser_markdown_to_html_test.go
+++ b/pkg/channels/telegram/parser_markdown_to_html_test.go
@@ -32,6 +32,11 @@ func Test_markdownToTelegramHTML(t *testing.T) {
input: "[click here](https://example.com/path)",
expected: `click here`,
},
+ {
+ name: "raw oauth url with underscores survives",
+ input: "Apri https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A8001%2Foauth2callback&code_challenge=abc_def&code_challenge_method=S256",
+ expected: `Apri https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A8001%2Foauth2callback&code_challenge=abc_def&code_challenge_method=S256`,
+ },
{
name: "link with underscores in URL is not corrupted by italic regex",
// Google Flights URLs use URL-safe base64 with underscores in the tfs param.
@@ -45,6 +50,11 @@ func Test_markdownToTelegramHTML(t *testing.T) {
input: "[first](https://a.com/path_one) and [second](https://b.com/path_two_x)",
expected: `first and second`,
},
+ {
+ name: "markdown link query params are escaped in href",
+ input: "[oauth](https://example.com/cb?response_type=code&client_id=test-client)",
+ expected: `oauth`,
+ },
{
name: "link label with HTML special chars is escaped",
input: "[a & b](https://example.com)",
@@ -55,6 +65,11 @@ func Test_markdownToTelegramHTML(t *testing.T) {
input: "a & b < c > d",
expected: "a & b < c > d",
},
+ {
+ name: "code block with language",
+ input: "```json\n{\n \"path\": \"README.md\"\n}\n```",
+ expected: "{\n \"path\": \"README.md\"\n}\n
",
+ },
}
for _, tc := range cases {
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 161108638..6bb8d3ce6 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -247,8 +247,9 @@ type SubTurnConfig struct {
}
type ToolFeedbackConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED"`
- MaxArgsLength int `json:"max_args_length" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED"`
+ MaxArgsLength int `json:"max_args_length" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH"`
+ SeparateMessages bool `json:"separate_messages" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_SEPARATE_MESSAGES"`
}
type AgentDefaults struct {
@@ -299,6 +300,13 @@ func (d *AgentDefaults) IsToolFeedbackEnabled() bool {
return d.ToolFeedback.Enabled
}
+// IsToolFeedbackSeparateMessagesEnabled returns true when each tool feedback
+// update should be sent as its own chat message instead of editing a single
+// in-place progress message.
+func (d *AgentDefaults) IsToolFeedbackSeparateMessagesEnabled() bool {
+ return d.ToolFeedback.SeparateMessages
+}
+
// GetModelName returns the effective model name for the agent defaults.
// It prefers the new "model_name" field but falls back to "model" for backward compatibility.
func (d *AgentDefaults) GetModelName() string {
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 624cc7305..2be1bcc67 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -787,6 +787,9 @@ func TestDefaultConfig_ToolFeedbackDisabled(t *testing.T) {
if cfg.Agents.Defaults.ToolFeedback.Enabled {
t.Fatal("DefaultConfig().Agents.Defaults.ToolFeedback.Enabled should be false")
}
+ if cfg.Agents.Defaults.ToolFeedback.SeparateMessages {
+ t.Fatal("DefaultConfig().Agents.Defaults.ToolFeedback.SeparateMessages should be false")
+ }
}
func TestLoadConfig_ToolFeedbackDefaultsFalseWhenUnset(t *testing.T) {
@@ -807,6 +810,9 @@ func TestLoadConfig_ToolFeedbackDefaultsFalseWhenUnset(t *testing.T) {
if cfg.Agents.Defaults.ToolFeedback.Enabled {
t.Fatal("agents.defaults.tool_feedback.enabled should remain false when unset in config file")
}
+ if cfg.Agents.Defaults.ToolFeedback.SeparateMessages {
+ t.Fatal("agents.defaults.tool_feedback.separate_messages should remain false when unset in config file")
+ }
}
func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) {
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index 35ef7cdd8..f3aaca7ab 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -35,8 +35,9 @@ func DefaultConfig() *Config {
SummarizeTokenPercent: 75,
SteeringMode: "one-at-a-time",
ToolFeedback: ToolFeedbackConfig{
- Enabled: false,
- MaxArgsLength: 300,
+ Enabled: false,
+ MaxArgsLength: 300,
+ SeparateMessages: false,
},
SplitOnMarker: false,
},
diff --git a/pkg/isolation/platform_windows.go b/pkg/isolation/platform_windows.go
index 9b39c85cf..1b3be8bd3 100644
--- a/pkg/isolation/platform_windows.go
+++ b/pkg/isolation/platform_windows.go
@@ -102,7 +102,7 @@ func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig,
return fmt.Errorf("open process for job assignment: %w", err)
}
- if err := windows.AssignProcessToJobObject(job, proc); err != nil {
+ if err = windows.AssignProcessToJobObject(job, proc); err != nil {
_ = windows.CloseHandle(proc)
_ = windows.CloseHandle(job)
if resources.token != 0 {
diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go
index f589f82a9..e28388827 100644
--- a/pkg/mcp/manager.go
+++ b/pkg/mcp/manager.go
@@ -25,6 +25,24 @@ type headerTransport struct {
headers map[string]string
}
+func expandHomeCommandPath(command string) string {
+ if command == "" || command[0] != '~' {
+ return command
+ }
+
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return command
+ }
+ if command == "~" {
+ return home
+ }
+ if strings.HasPrefix(command, "~/") || strings.HasPrefix(command, "~\\") {
+ return filepath.Join(home, command[2:])
+ }
+ return command
+}
+
func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Clone the request to avoid modifying the original
req = req.Clone(req.Context())
@@ -324,7 +342,7 @@ func (m *Manager) ConnectServer(
"command": cfg.Command,
})
// Create command with context
- cmd := exec.CommandContext(ctx, cfg.Command, cfg.Args...)
+ cmd := exec.CommandContext(ctx, expandHomeCommandPath(cfg.Command), cfg.Args...)
// Build environment variables with proper override semantics
// Use a map to ensure config variables override file variables
diff --git a/pkg/mcp/manager_test.go b/pkg/mcp/manager_test.go
index f353942ab..fff315655 100644
--- a/pkg/mcp/manager_test.go
+++ b/pkg/mcp/manager_test.go
@@ -136,6 +136,22 @@ func TestLoadEnvFileNotFound(t *testing.T) {
}
}
+func TestExpandHomeCommandPath(t *testing.T) {
+ homeDir := t.TempDir()
+ t.Setenv("HOME", homeDir)
+ t.Setenv("USERPROFILE", homeDir)
+
+ want := filepath.Join(homeDir, "bin", "my-mcp")
+ got := expandHomeCommandPath("~" + string(os.PathSeparator) + filepath.Join("bin", "my-mcp"))
+ if got != want {
+ t.Fatalf("expandHomeCommandPath() = %q, want %q", got, want)
+ }
+
+ if got := expandHomeCommandPath("npx"); got != "npx" {
+ t.Fatalf("expandHomeCommandPath() should leave bare commands unchanged, got %q", got)
+ }
+}
+
func TestEnvFilePriority(t *testing.T) {
// Create a temporary .env file
tmpDir := t.TempDir()
diff --git a/pkg/pid/pidfile.go b/pkg/pid/pidfile.go
index f7c1f42b2..00601195f 100644
--- a/pkg/pid/pidfile.go
+++ b/pkg/pid/pidfile.go
@@ -58,7 +58,12 @@ func WritePidFile(homePath, host string, port int) (*PidFileData, error) {
if data, err := readPidFileUnlocked(pidPath); err == nil {
if os.Getpid() != data.PID {
logger.Infof("found pid file (PID: %d, version: %s)", data.PID, data.Version)
- if isProcessRunning(data.PID) {
+ // PID 1 is typically init/systemd on the host or the entrypoint
+ // inside a container. When a container stops and leaves behind a
+ // PID file on a shared volume, the host's PID 1 (init) would
+ // 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)
}
logger.Warnf("not running (PID: %d) so will remove the pid file: %s", data.PID, pidPath)
@@ -124,6 +129,14 @@ func ReadPidFileWithCheck(homePath string) *PidFileData {
return nil
}
+ // Treat PID 1 as stale when we are not PID 1 ourselves (container
+ // leftover on a shared volume — host PID 1 is init, not gateway).
+ if data.PID == 1 && os.Getpid() != 1 {
+ logger.Debugf("stale container PID 1, remove pid file: %s", pidPath)
+ os.Remove(pidPath)
+ return nil
+ }
+
if !isProcessRunning(data.PID) {
logger.Debugf("process not running, remove pid file: %s", pidPath)
os.Remove(pidPath)
diff --git a/pkg/pid/pidfile_test.go b/pkg/pid/pidfile_test.go
index 2da44bbbc..2d3c11f63 100644
--- a/pkg/pid/pidfile_test.go
+++ b/pkg/pid/pidfile_test.go
@@ -278,6 +278,46 @@ func TestRemovePidFileIfPIDMismatch(t *testing.T) {
}
}
+// TestWritePidFileContainerPID1 verifies that a leftover PID file with PID 1
+// (typical container entrypoint) is treated as stale and overwritten.
+func TestWritePidFileContainerPID1(t *testing.T) {
+ dir := tmpDir(t)
+
+ stale := PidFileData{PID: 1, Token: "deadbeef12345678deadbeef12345678"}
+ raw, _ := json.MarshalIndent(stale, "", " ")
+ os.WriteFile(filepath.Join(dir, pidFileName), raw, 0o600)
+
+ data, err := WritePidFile(dir, "127.0.0.1", 18790)
+ if err != nil {
+ t.Fatalf("WritePidFile should treat PID 1 as stale, got error: %v", err)
+ }
+ if data.PID != os.Getpid() {
+ t.Errorf("PID = %d, want %d", data.PID, os.Getpid())
+ }
+}
+
+// TestReadPidFileWithCheckContainerPID1 verifies that a leftover PID file
+// with PID 1 is treated as stale and cleaned up.
+func TestReadPidFileWithCheckContainerPID1(t *testing.T) {
+ if os.Getpid() == 1 {
+ t.Skip("test not meaningful when running as PID 1")
+ }
+ dir := tmpDir(t)
+
+ stale := PidFileData{PID: 1, Token: "deadbeef12345678deadbeef12345678"}
+ raw, _ := json.MarshalIndent(stale, "", " ")
+ os.WriteFile(filepath.Join(dir, pidFileName), raw, 0o600)
+
+ data := ReadPidFileWithCheck(dir)
+ if data != nil {
+ t.Error("expected nil for PID 1 leftover")
+ }
+
+ if _, err := os.Stat(filepath.Join(dir, pidFileName)); !os.IsNotExist(err) {
+ t.Error("PID 1 leftover file should be removed")
+ }
+}
+
// TestReadPidFileUnlockedInvalidJSON returns error for malformed content.
func TestReadPidFileUnlockedInvalidJSON(t *testing.T) {
dir := tmpDir(t)
diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go
index fb019178c..7aba24141 100644
--- a/pkg/providers/protocoltypes/types.go
+++ b/pkg/providers/protocoltypes/types.go
@@ -61,6 +61,13 @@ type ContentBlock struct {
Type string `json:"type"` // "text"
Text string `json:"text"`
CacheControl *CacheControl `json:"cache_control,omitempty"`
+
+ // Prompt metadata is internal to the agent runtime. It records which
+ // structured prompt segment produced this block without changing provider
+ // JSON.
+ PromptLayer string `json:"-"`
+ PromptSlot string `json:"-"`
+ PromptSource string `json:"-"`
}
type Attachment struct {
@@ -81,11 +88,24 @@ type Message struct {
SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
+
+ // Prompt metadata is internal to the agent runtime. It records where a
+ // message or system part came from without changing provider/session JSON.
+ PromptLayer string `json:"-"`
+ PromptSlot string `json:"-"`
+ PromptSource string `json:"-"`
}
type ToolDefinition struct {
Type string `json:"type"`
Function ToolFunctionDefinition `json:"function"`
+
+ // Prompt metadata is internal to the agent runtime. Tool definitions are
+ // model-visible capability prompts even though providers send them outside
+ // the system message.
+ PromptLayer string `json:"-"`
+ PromptSlot string `json:"-"`
+ PromptSource string `json:"-"`
}
type ToolFunctionDefinition struct {
diff --git a/pkg/tools/integration/mcp_tool.go b/pkg/tools/integration/mcp_tool.go
index 340bb9e8e..78c348316 100644
--- a/pkg/tools/integration/mcp_tool.go
+++ b/pkg/tools/integration/mcp_tool.go
@@ -15,6 +15,7 @@ import (
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
+ toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
)
// MCPManager defines the interface for MCP manager operations
@@ -161,6 +162,14 @@ func (t *MCPTool) Description() string {
return fmt.Sprintf("[MCP:%s] %s", t.serverName, desc)
}
+func (t *MCPTool) PromptMetadata() toolshared.PromptMetadata {
+ return toolshared.PromptMetadata{
+ Layer: toolshared.ToolPromptLayerCapability,
+ Slot: toolshared.ToolPromptSlotMCP,
+ Source: "mcp:" + sanitizeIdentifierComponent(t.serverName),
+ }
+}
+
// Parameters returns the tool parameters schema
func (t *MCPTool) Parameters() map[string]any {
// The InputSchema is already a JSON Schema object
diff --git a/pkg/tools/integration/mcp_tool_test.go b/pkg/tools/integration/mcp_tool_test.go
index e5c54abb6..7b0b2cd5a 100644
--- a/pkg/tools/integration/mcp_tool_test.go
+++ b/pkg/tools/integration/mcp_tool_test.go
@@ -11,6 +11,7 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/sipeed/picoclaw/pkg/media"
+ toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
)
// MockMCPManager is a mock implementation of MCPManager interface for testing
@@ -104,6 +105,22 @@ func TestMCPTool_Name(t *testing.T) {
}
}
+func TestMCPTool_PromptMetadata(t *testing.T) {
+ manager := &MockMCPManager{}
+ tool := NewMCPTool(manager, "GitHub Server", &mcp.Tool{Name: "create_issue"})
+
+ metadata := tool.PromptMetadata()
+ if metadata.Layer != toolshared.ToolPromptLayerCapability {
+ t.Fatalf("metadata.Layer = %q, want %q", metadata.Layer, toolshared.ToolPromptLayerCapability)
+ }
+ if metadata.Slot != toolshared.ToolPromptSlotMCP {
+ t.Fatalf("metadata.Slot = %q, want %q", metadata.Slot, toolshared.ToolPromptSlotMCP)
+ }
+ if metadata.Source != "mcp:github_server" {
+ t.Fatalf("metadata.Source = %q, want mcp:github_server", metadata.Source)
+ }
+}
+
// TestMCPTool_Description verifies tool description generation
func TestMCPTool_Description(t *testing.T) {
tests := []struct {
diff --git a/pkg/tools/integration/web.go b/pkg/tools/integration/web.go
index 56663ecda..75821e40d 100644
--- a/pkg/tools/integration/web.go
+++ b/pkg/tools/integration/web.go
@@ -58,8 +58,6 @@ var (
reSogouRealURL = regexp.MustCompile(`url=([^&]+)`)
)
-var preferredWebSearchLanguage atomic.Value
-
type APIKeyPool struct {
keys []string
current uint32
@@ -250,27 +248,6 @@ func mapBaiduRecencyFilter(rangeCode string) string {
}
}
-func normalizePreferredWebSearchLanguage(lang string) string {
- lang = strings.ToLower(strings.TrimSpace(lang))
- switch {
- case strings.HasPrefix(lang, "zh"), lang == "chinese":
- return "zh"
- case strings.HasPrefix(lang, "en"), lang == "english":
- return "en"
- default:
- return ""
- }
-}
-
-func SetPreferredWebSearchLanguage(lang string) {
- preferredWebSearchLanguage.Store(normalizePreferredWebSearchLanguage(lang))
-}
-
-func GetPreferredWebSearchLanguage() string {
- lang, _ := preferredWebSearchLanguage.Load().(string)
- return lang
-}
-
type BraveSearchProvider struct {
keyPool *APIKeyPool
proxy string
@@ -1420,7 +1397,7 @@ func containsLatinLetter(text string) bool {
func prefersDuckDuckGoQuery(text string) bool {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
- return GetPreferredWebSearchLanguage() == "en"
+ return false
}
if containsHan(trimmed) {
return false
@@ -1428,7 +1405,7 @@ func prefersDuckDuckGoQuery(text string) bool {
if containsLatinLetter(trimmed) {
return true
}
- return GetPreferredWebSearchLanguage() == "en"
+ return false
}
func (opts WebSearchToolOptions) buildProviderResolver() (func(query string) (SearchProvider, int), error) {
diff --git a/pkg/tools/integration/web_test.go b/pkg/tools/integration/web_test.go
index d47d8e7c9..ba6b3da45 100644
--- a/pkg/tools/integration/web_test.go
+++ b/pkg/tools/integration/web_test.go
@@ -1778,11 +1778,6 @@ func TestApplySogouRangeHint(t *testing.T) {
}
func TestPrefersDuckDuckGoQuery(t *testing.T) {
- SetPreferredWebSearchLanguage("")
- t.Cleanup(func() {
- SetPreferredWebSearchLanguage("")
- })
-
tests := []struct {
name string
query string
@@ -1805,19 +1800,9 @@ func TestPrefersDuckDuckGoQuery(t *testing.T) {
}
}
-func TestPrefersDuckDuckGoQuery_FallsBackToPreferredLanguage(t *testing.T) {
- SetPreferredWebSearchLanguage("en")
- t.Cleanup(func() {
- SetPreferredWebSearchLanguage("")
- })
-
- if !prefersDuckDuckGoQuery("2026 04 15") {
- t.Fatal("numeric query should prefer DuckDuckGo when preferred language is English")
- }
-
- SetPreferredWebSearchLanguage("zh")
+func TestPrefersDuckDuckGoQuery_DoesNotUseGlobalLanguageFallback(t *testing.T) {
if prefersDuckDuckGoQuery("2026 04 15") {
- t.Fatal("numeric query should prefer Sogou when preferred language is Chinese")
+ t.Fatal("numeric query should default to Sogou when no script-specific hint is present")
}
}
diff --git a/pkg/tools/integration_facade.go b/pkg/tools/integration_facade.go
index b05a22fe2..193ecd6f5 100644
--- a/pkg/tools/integration_facade.go
+++ b/pkg/tools/integration_facade.go
@@ -65,14 +65,6 @@ func NewAPIKeyPool(keys []string) *APIKeyPool {
return integrationtools.NewAPIKeyPool(keys)
}
-func SetPreferredWebSearchLanguage(lang string) {
- integrationtools.SetPreferredWebSearchLanguage(lang)
-}
-
-func GetPreferredWebSearchLanguage() string {
- return integrationtools.GetPreferredWebSearchLanguage()
-}
-
func WebSearchToolOptionsFromConfig(cfg *config.Config) WebSearchToolOptions {
return integrationtools.WebSearchToolOptionsFromConfig(cfg)
}
diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go
index e51dff71a..0ff9293a3 100644
--- a/pkg/tools/registry.go
+++ b/pkg/tools/registry.go
@@ -352,6 +352,7 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
name, _ := fn["name"].(string)
desc, _ := fn["description"].(string)
params, _ := fn["parameters"].(map[string]any)
+ metadata := promptMetadataForTool(entry.Tool)
definitions = append(definitions, providers.ToolDefinition{
Type: "function",
@@ -360,11 +361,35 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
Description: desc,
Parameters: params,
},
+ PromptLayer: metadata.Layer,
+ PromptSlot: metadata.Slot,
+ PromptSource: metadata.Source,
})
}
return definitions
}
+func promptMetadataForTool(tool Tool) PromptMetadata {
+ metadata := PromptMetadata{
+ Layer: ToolPromptLayerCapability,
+ Slot: ToolPromptSlotTooling,
+ Source: ToolPromptSourceRegistry,
+ }
+ if provider, ok := tool.(PromptMetadataProvider); ok {
+ provided := provider.PromptMetadata()
+ if provided.Layer != "" {
+ metadata.Layer = provided.Layer
+ }
+ if provided.Slot != "" {
+ metadata.Slot = provided.Slot
+ }
+ if provided.Source != "" {
+ metadata.Source = provided.Source
+ }
+ }
+ return metadata
+}
+
// List returns a list of all registered tool names.
func (r *ToolRegistry) List() []string {
r.mu.RLock()
diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go
index 16bd30928..eac96382f 100644
--- a/pkg/tools/registry_test.go
+++ b/pkg/tools/registry_test.go
@@ -39,6 +39,15 @@ func (m *mockContextAwareTool) Execute(ctx context.Context, _ map[string]any) *T
return m.result
}
+type mockPromptMetadataTool struct {
+ mockRegistryTool
+ metadata PromptMetadata
+}
+
+func (m *mockPromptMetadataTool) PromptMetadata() PromptMetadata {
+ return m.metadata
+}
+
type mockAsyncRegistryTool struct {
mockRegistryTool
lastCB AsyncCallback
@@ -375,6 +384,47 @@ func TestToolToSchema(t *testing.T) {
}
}
+func TestToolRegistry_ToProviderDefsAttachesPromptMetadata(t *testing.T) {
+ r := NewToolRegistry()
+ r.Register(newMockTool("native", "native tool"))
+ r.Register(&mockPromptMetadataTool{
+ mockRegistryTool: mockRegistryTool{
+ name: "mcp_demo",
+ desc: "mcp tool",
+ params: map[string]any{"type": "object"},
+ },
+ metadata: PromptMetadata{
+ Layer: ToolPromptLayerCapability,
+ Slot: ToolPromptSlotMCP,
+ Source: "mcp:demo",
+ },
+ })
+
+ defs := r.ToProviderDefs()
+ if len(defs) != 2 {
+ t.Fatalf("ToProviderDefs() len = %d, want 2", len(defs))
+ }
+
+ byName := make(map[string]providers.ToolDefinition, len(defs))
+ for _, def := range defs {
+ byName[def.Function.Name] = def
+ }
+
+ native := byName["native"]
+ if native.PromptLayer != ToolPromptLayerCapability ||
+ native.PromptSlot != ToolPromptSlotTooling ||
+ native.PromptSource != ToolPromptSourceRegistry {
+ t.Fatalf("native prompt metadata = %#v, want default tooling source", native)
+ }
+
+ mcp := byName["mcp_demo"]
+ if mcp.PromptLayer != ToolPromptLayerCapability ||
+ mcp.PromptSlot != ToolPromptSlotMCP ||
+ mcp.PromptSource != "mcp:demo" {
+ t.Fatalf("mcp prompt metadata = %#v, want mcp source", mcp)
+ }
+}
+
func TestToolRegistry_Clone(t *testing.T) {
r := NewToolRegistry()
r.Register(newMockTool("read_file", "reads files"))
diff --git a/pkg/tools/search_tool.go b/pkg/tools/search_tool.go
index f41c80d90..c5884c9de 100644
--- a/pkg/tools/search_tool.go
+++ b/pkg/tools/search_tool.go
@@ -34,6 +34,14 @@ func (t *RegexSearchTool) Description() string {
return "Search available hidden tools on-demand using a regex pattern. Returns JSON schemas of discovered tools."
}
+func (t *RegexSearchTool) PromptMetadata() PromptMetadata {
+ return PromptMetadata{
+ Layer: ToolPromptLayerCapability,
+ Slot: ToolPromptSlotTooling,
+ Source: ToolPromptSourceDiscovery,
+ }
+}
+
func (t *RegexSearchTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
@@ -95,6 +103,14 @@ func (t *BM25SearchTool) Description() string {
return "Search available hidden tools on-demand using natural language query describing the action you need to perform. Returns JSON schemas of discovered tools."
}
+func (t *BM25SearchTool) PromptMetadata() PromptMetadata {
+ return PromptMetadata{
+ Layer: ToolPromptLayerCapability,
+ Slot: ToolPromptSlotTooling,
+ Source: ToolPromptSourceDiscovery,
+ }
+}
+
func (t *BM25SearchTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
diff --git a/pkg/tools/shared/base.go b/pkg/tools/shared/base.go
index 5498d24ab..298e1b478 100644
--- a/pkg/tools/shared/base.go
+++ b/pkg/tools/shared/base.go
@@ -14,6 +14,24 @@ type Tool interface {
Execute(ctx context.Context, args map[string]any) *ToolResult
}
+const (
+ ToolPromptLayerCapability = "capability"
+ ToolPromptSlotTooling = "tooling"
+ ToolPromptSlotMCP = "mcp"
+ ToolPromptSourceRegistry = "tool_registry:native"
+ ToolPromptSourceDiscovery = "tool_registry:discovery"
+)
+
+type PromptMetadata struct {
+ Layer string
+ Slot string
+ Source string
+}
+
+type PromptMetadataProvider interface {
+ PromptMetadata() PromptMetadata
+}
+
// --- Request-scoped tool context (channel / chatID) ---
//
// Carried via context.Value so that concurrent tool calls each receive
diff --git a/pkg/tools/shared_facade.go b/pkg/tools/shared_facade.go
index 6e40e4e3a..8409ea060 100644
--- a/pkg/tools/shared_facade.go
+++ b/pkg/tools/shared_facade.go
@@ -22,12 +22,20 @@ type (
Tool = toolshared.Tool
AsyncCallback = toolshared.AsyncCallback
AsyncExecutor = toolshared.AsyncExecutor
+ PromptMetadata = toolshared.PromptMetadata
+ PromptMetadataProvider = toolshared.PromptMetadataProvider
ToolResult = toolshared.ToolResult
)
const (
handledToolLLMNote = toolshared.HandledToolLLMNote
artifactPathsLLMNote = toolshared.ArtifactPathsLLMNote
+
+ ToolPromptLayerCapability = toolshared.ToolPromptLayerCapability
+ ToolPromptSlotTooling = toolshared.ToolPromptSlotTooling
+ ToolPromptSlotMCP = toolshared.ToolPromptSlotMCP
+ ToolPromptSourceRegistry = toolshared.ToolPromptSourceRegistry
+ ToolPromptSourceDiscovery = toolshared.ToolPromptSourceDiscovery
)
func WithToolContext(ctx context.Context, channel, chatID string) context.Context {
diff --git a/pkg/utils/tool_feedback.go b/pkg/utils/tool_feedback.go
index 1a8b6c747..de7cb467e 100644
--- a/pkg/utils/tool_feedback.go
+++ b/pkg/utils/tool_feedback.go
@@ -7,21 +7,31 @@ import (
const ToolFeedbackContinuationHint = "Continuing the current task."
-// FormatToolFeedbackMessage renders the model-provided explanation for why a
-// tool is being executed. When the model does not provide one, it keeps only
-// the tool line and does not expose raw arguments or fallback text.
-func FormatToolFeedbackMessage(toolName, explanation string) string {
+// FormatToolFeedbackMessage renders a tool feedback message for chat channels.
+// It keeps the tool name on the first line for animation and can include both
+// a human explanation and the serialized tool arguments in the body.
+func FormatToolFeedbackMessage(toolName, explanation, argsPreview string) string {
toolName = strings.TrimSpace(toolName)
explanation = strings.TrimSpace(explanation)
+ argsPreview = strings.TrimSpace(argsPreview)
+
+ bodyLines := make([]string, 0, 2)
+ if explanation != "" {
+ bodyLines = append(bodyLines, explanation)
+ }
+ if argsPreview != "" {
+ bodyLines = append(bodyLines, "```json\n"+argsPreview+"\n```")
+ }
+ body := strings.Join(bodyLines, "\n")
if toolName == "" {
- return explanation
+ return body
}
- if explanation == "" {
+ if body == "" {
return fmt.Sprintf("\U0001f527 `%s`", toolName)
}
- return fmt.Sprintf("\U0001f527 `%s`\n%s", toolName, explanation)
+ return fmt.Sprintf("\U0001f527 `%s`\n%s", toolName, body)
}
// FitToolFeedbackMessage keeps tool feedback within a single outbound message.
diff --git a/pkg/utils/tool_feedback_test.go b/pkg/utils/tool_feedback_test.go
index 316ce2408..c30f53827 100644
--- a/pkg/utils/tool_feedback_test.go
+++ b/pkg/utils/tool_feedback_test.go
@@ -6,29 +6,38 @@ func TestFormatToolFeedbackMessage(t *testing.T) {
got := FormatToolFeedbackMessage(
"read_file",
"I will read README.md first to confirm the current project structure.",
+ "{\n \"path\": \"README.md\"\n}",
)
- want := "\U0001f527 `read_file`\nI will read README.md first to confirm the current project structure."
+ want := "\U0001f527 `read_file`\nI will read README.md first to confirm the current project structure.\n```json\n{\n \"path\": \"README.md\"\n}\n```"
if got != want {
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
}
}
-func TestFormatToolFeedbackMessage_EmptyExplanationKeepsOnlyToolLine(t *testing.T) {
- got := FormatToolFeedbackMessage("read_file", "")
- want := "\U0001f527 `read_file`"
+func TestFormatToolFeedbackMessage_EmptyExplanationShowsArgs(t *testing.T) {
+ got := FormatToolFeedbackMessage("read_file", "", "{\n \"path\": \"README.md\"\n}")
+ want := "\U0001f527 `read_file`\n```json\n{\n \"path\": \"README.md\"\n}\n```"
if got != want {
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
}
}
func TestFormatToolFeedbackMessage_EmptyToolNameOmitsToolLine(t *testing.T) {
- got := FormatToolFeedbackMessage("", "Continue drafting the final response.")
+ got := FormatToolFeedbackMessage("", "Continue drafting the final response.", "")
want := "Continue drafting the final response."
if got != want {
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
}
}
+func TestFormatToolFeedbackMessage_EmptyExplanationAndArgsKeepsOnlyToolLine(t *testing.T) {
+ got := FormatToolFeedbackMessage("read_file", "", "")
+ want := "\U0001f527 `read_file`"
+ if got != want {
+ t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
+ }
+}
+
func TestFitToolFeedbackMessage_TruncatesBodyWithinSingleMessage(t *testing.T) {
got := FitToolFeedbackMessage(
"\U0001f527 `read_file`\nRead README.md first to confirm the current project structure.",
diff --git a/scripts/copydir.go b/scripts/copydir.go
new file mode 100644
index 000000000..74eff6c72
--- /dev/null
+++ b/scripts/copydir.go
@@ -0,0 +1,84 @@
+package main
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+)
+
+func main() {
+ if len(os.Args) != 3 {
+ fmt.Fprintf(os.Stderr, "usage: go run scripts/copydir.go \n")
+ os.Exit(2)
+ }
+
+ src := os.Args[1]
+ dst := os.Args[2]
+
+ if err := os.RemoveAll(dst); err != nil {
+ fmt.Fprintf(os.Stderr, "remove %s: %v\n", dst, err)
+ os.Exit(1)
+ }
+
+ if err := copyTree(src, dst); err != nil {
+ fmt.Fprintf(os.Stderr, "copy %s -> %s: %v\n", src, dst, err)
+ os.Exit(1)
+ }
+}
+
+func copyTree(src, dst string) error {
+ info, err := os.Stat(src)
+ if err != nil {
+ return err
+ }
+ if !info.IsDir() {
+ return fmt.Errorf("source is not a directory: %s", src)
+ }
+
+ return filepath.Walk(src, func(path string, entry os.FileInfo, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+
+ rel, err := filepath.Rel(src, path)
+ if err != nil {
+ return err
+ }
+
+ target := dst
+ if rel != "." {
+ target = filepath.Join(dst, rel)
+ }
+
+ if entry.IsDir() {
+ return os.MkdirAll(target, entry.Mode())
+ }
+
+ return copyFile(path, target, entry.Mode())
+ })
+}
+
+func copyFile(src, dst string, mode os.FileMode) error {
+ if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
+ return err
+ }
+
+ in, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+
+ out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+
+ if _, err := io.Copy(out, in); err != nil {
+ return err
+ }
+
+ return out.Close()
+}
diff --git a/web/Makefile b/web/Makefile
index 4dca810e7..254c439e9 100644
--- a/web/Makefile
+++ b/web/Makefile
@@ -2,15 +2,24 @@
build-android-arm64 build-android-bundle
# Go variables
-GO?=CGO_ENABLED=0 go
+GO?=go
WEB_GO?=$(GO)
+CGO_ENABLED?=0
GO_BUILD_TAGS?=goolm,stdjson
GOFLAGS?=-v -tags $(GO_BUILD_TAGS)
+GOCACHE?=$(abspath ../.cache/go-build)
+GOMODCACHE?=$(abspath ../.cache/go-mod)
+GOTOOLCHAIN?=local
+export CGO_ENABLED
+export GOCACHE
+export GOMODCACHE
+export GOTOOLCHAIN
# Build variables
BUILD_DIR=build
-OUTPUT?=$(BUILD_DIR)/picoclaw-launcher
-OUTPUT_ANDROID_ARM64?=$(BUILD_DIR)/picoclaw-launcher-android-arm64
+EXT=
+OUTPUT?=$(BUILD_DIR)/picoclaw-launcher$(EXT)
+OUTPUT_ANDROID_ARM64?=$(BUILD_DIR)/picoclaw-launcher-android-arm64$(EXT)
FRONTEND_DIR=frontend
FRONTEND_INSTALL_STAMP=$(FRONTEND_DIR)/node_modules/.picoclaw-install-stamp
BACKEND_DIR=backend
@@ -19,18 +28,47 @@ PICOCLAW_BINARY_NAME=picoclaw
PICOCLAW_BINARY?=$(abspath ../build/$(PICOCLAW_BINARY_NAME))
LAUNCHER_GUI_LDFLAG=
+ifeq ($(OS),Windows_NT)
+ POWERSHELL=powershell -NoProfile -Command
+ WINDOWS_GOARCH_RAW:=$(strip $(shell go env GOARCH 2>NUL))
+endif
+
# Version
-VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
-GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
-BUILD_TIME=$(shell date +%FT%T%z)
-GO_VERSION=$(shell $(WEB_GO) version | awk '{print $$3}')
+ifeq ($(OS),Windows_NT)
+ VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>NUL))
+ GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>NUL))
+ BUILD_TIME_RAW:=$(strip $(shell powershell -NoProfile -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK'"))
+ GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>NUL))
+else
+ VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>/dev/null))
+ GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>/dev/null))
+ BUILD_TIME_RAW:=$(strip $(shell date +%FT%T%z))
+ GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>/dev/null))
+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)
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
# OS detection
-UNAME_S:=$(shell uname -s)
-UNAME_M:=$(shell uname -m)
+ifeq ($(OS),Windows_NT)
+ UNAME_S=Windows
+ ifeq ($(WINDOWS_GOARCH_RAW),amd64)
+ UNAME_M=x86_64
+ else ifeq ($(WINDOWS_GOARCH_RAW),arm64)
+ UNAME_M=arm64
+ else ifeq ($(WINDOWS_GOARCH_RAW),386)
+ UNAME_M=x86
+ else
+ UNAME_M=$(if $(WINDOWS_GOARCH_RAW),$(WINDOWS_GOARCH_RAW),x86_64)
+ endif
+else
+ UNAME_S:=$(shell uname -s)
+ UNAME_M:=$(shell uname -m)
+endif
# Platform-specific settings
ifeq ($(UNAME_S),Linux)
@@ -62,7 +100,14 @@ else ifeq ($(UNAME_S),Darwin)
endif
else ifeq ($(UNAME_S),Windows)
PLATFORM=windows
- ARCH=$(UNAME_M)
+ ifeq ($(UNAME_M),x86_64)
+ ARCH=amd64
+ else ifeq ($(UNAME_M),arm64)
+ ARCH=arm64
+ else
+ ARCH=$(UNAME_M)
+ endif
+ EXT=.exe
PICOCLAW_BINARY_NAME=picoclaw.exe
LAUNCHER_GUI_LDFLAG=-H=windowsgui
else
@@ -91,21 +136,36 @@ dev-backend:
# Build frontend and embed into Go binary
build: build-frontend
+ifeq ($(OS),Windows_NT)
+ @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path (Split-Path -Parent '$(OUTPUT)') | Out-Null"
+else
@mkdir -p "$$(dirname "$(OUTPUT)")"
+endif
${WEB_GO} build $(GOFLAGS) -ldflags "$(LAUNCHER_LDFLAGS)" -o "$(OUTPUT)" ./$(BACKEND_DIR)/
# Build launcher for Android ARM64 (frontend must already be built)
build-android-arm64: build-frontend
+ifeq ($(OS),Windows_NT)
+ @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null"
+else
@mkdir -p $(BUILD_DIR)
+endif
GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(OUTPUT_ANDROID_ARM64)" ./$(BACKEND_DIR)/
# Build launcher for all Android architectures
build-android-bundle: build-frontend
+ifeq ($(OS),Windows_NT)
+ @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null"
+else
@mkdir -p $(BUILD_DIR)
+endif
GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(BUILD_DIR)/picoclaw-launcher-android-arm64" ./$(BACKEND_DIR)/
@echo "All Android launcher builds complete"
build-frontend:
+ifeq ($(OS),Windows_NT)
+ @$(POWERSHELL) "if ((-not (Test-Path -LiteralPath '$(FRONTEND_DIR)/node_modules')) -or (-not (Test-Path -LiteralPath '$(FRONTEND_DIR)/node_modules/.bin/tsc')) -or (-not (Test-Path -LiteralPath '$(FRONTEND_INSTALL_STAMP)')) -or ((Get-Content -LiteralPath '$(FRONTEND_INSTALL_STAMP)' -Raw).Trim() -ne (((Get-FileHash -LiteralPath '$(FRONTEND_DIR)/package.json' -Algorithm SHA256).Hash + ':' + (Get-FileHash -LiteralPath '$(FRONTEND_DIR)/pnpm-lock.yaml' -Algorithm SHA256).Hash)))) { Write-Host 'Installing frontend dependencies...'; Push-Location '$(FRONTEND_DIR)'; try { pnpm install --frozen-lockfile } finally { Pop-Location }; Set-Content -LiteralPath '$(FRONTEND_INSTALL_STAMP)' -Value (((Get-FileHash -LiteralPath '$(FRONTEND_DIR)/package.json' -Algorithm SHA256).Hash + ':' + (Get-FileHash -LiteralPath '$(FRONTEND_DIR)/pnpm-lock.yaml' -Algorithm SHA256).Hash)) -NoNewline }"
+else
@expected_stamp="$$(cat $(FRONTEND_DIR)/package.json $(FRONTEND_DIR)/pnpm-lock.yaml | cksum | awk '{print $$1 ":" $$2}')"; \
if [ ! -d $(FRONTEND_DIR)/node_modules ] || \
[ ! -x $(FRONTEND_DIR)/node_modules/.bin/tsc ] || \
@@ -115,12 +175,17 @@ build-frontend:
(cd $(FRONTEND_DIR) && CI=true pnpm install --frozen-lockfile) && \
printf '%s\n' "$$expected_stamp" > $(FRONTEND_INSTALL_STAMP); \
fi
+endif
@echo "Building frontend..."
@cd $(FRONTEND_DIR) && pnpm build:backend
build-dev-picoclaw:
@echo "Building picoclaw for launcher development..."
+ifeq ($(OS),Windows_NT)
+ @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path (Split-Path -Parent '$(PICOCLAW_BINARY)') | Out-Null"
+else
@mkdir -p "$$(dirname "$(PICOCLAW_BINARY)")"
+endif
@$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o "$(PICOCLAW_BINARY)" ../cmd/picoclaw
# Run all tests
@@ -135,5 +200,10 @@ lint:
# Clean build artifacts
clean:
+ifeq ($(OS),Windows_NT)
+ @$(POWERSHELL) "$$paths=@('$(FRONTEND_DIR)/dist','$(BACKEND_DIST)','$(BUILD_DIR)'); foreach($$p in $$paths){ if (Test-Path -LiteralPath $$p) { Remove-Item -LiteralPath $$p -Recurse -Force } }"
+ @node $(FRONTEND_DIR)/scripts/ensure-backend-gitkeep.cjs
+else
rm -rf $(FRONTEND_DIR)/dist $(BACKEND_DIST) $(BUILD_DIR)
node $(FRONTEND_DIR)/scripts/ensure-backend-gitkeep.cjs
+endif
diff --git a/web/backend/api/router.go b/web/backend/api/router.go
index f4ac78ab4..76f63607e 100644
--- a/web/backend/api/router.go
+++ b/web/backend/api/router.go
@@ -89,7 +89,6 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Skills and tools support/actions
h.registerSkillRoutes(mux)
h.registerToolRoutes(mux)
- h.registerUIRoutes(mux)
// OS startup / launch-at-login
h.registerStartupRoutes(mux)
diff --git a/web/backend/api/session.go b/web/backend/api/session.go
index 824130c4c..6fc6ae72d 100644
--- a/web/backend/api/session.go
+++ b/web/backend/api/session.go
@@ -2,6 +2,7 @@ package api
import (
"bufio"
+ "bytes"
"encoding/json"
"errors"
"net/http"
@@ -601,7 +602,15 @@ func toolSummaryContainsContent(summary, content string) bool {
}
_, body, hasBody := strings.Cut(summary, "\n")
- return hasBody && strings.TrimSpace(body) == content
+ if !hasBody {
+ return false
+ }
+ body = strings.TrimSpace(body)
+ if body == content {
+ return true
+ }
+ firstSection, _, _ := strings.Cut(body, "\n```")
+ return strings.TrimSpace(firstSection) == content
}
func sessionAttachments(msg providers.Message) []sessionChatAttachment {
@@ -723,7 +732,8 @@ func visibleAssistantToolSummaryMessages(
Role: "assistant",
Content: utils.FormatToolFeedbackMessage(
name,
- visibleAssistantToolSummaryText(tc, toolFeedbackMaxArgsLength),
+ visibleAssistantToolFeedbackExplanation(tc, toolFeedbackMaxArgsLength),
+ visibleAssistantToolArgsPreview(tc, toolFeedbackMaxArgsLength),
),
Timestamp: timestamp,
})
@@ -732,7 +742,7 @@ func visibleAssistantToolSummaryMessages(
return messages
}
-func visibleAssistantToolSummaryText(
+func visibleAssistantToolFeedbackExplanation(
tc providers.ToolCall,
toolFeedbackMaxArgsLength int,
) string {
@@ -741,18 +751,32 @@ func visibleAssistantToolSummaryText(
return utils.Truncate(explanation, toolFeedbackMaxArgsLength)
}
}
+ return ""
+}
+func visibleAssistantToolArgsPreview(
+ tc providers.ToolCall,
+ toolFeedbackMaxArgsLength int,
+) string {
argsJSON := ""
if tc.Function != nil {
argsJSON = tc.Function.Arguments
}
if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 {
- if encodedArgs, err := json.Marshal(tc.Arguments); err == nil {
+ if encodedArgs, err := json.MarshalIndent(tc.Arguments, "", " "); err == nil {
argsJSON = string(encodedArgs)
}
}
+ argsJSON = strings.TrimSpace(argsJSON)
+ if argsJSON == "" {
+ return ""
+ }
+ var pretty bytes.Buffer
+ if err := json.Indent(&pretty, []byte(argsJSON), "", " "); err == nil {
+ argsJSON = pretty.String()
+ }
- return utils.Truncate(strings.TrimSpace(argsJSON), toolFeedbackMaxArgsLength)
+ return utils.Truncate(argsJSON, toolFeedbackMaxArgsLength)
}
func visibleAssistantToolMessages(toolCalls []providers.ToolCall, timestamp string) []sessionChatMessage {
diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go
index 62233e867..adf3618ff 100644
--- a/web/backend/api/session_test.go
+++ b/web/backend/api/session_test.go
@@ -1063,8 +1063,11 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T)
if !strings.Contains(resp.Messages[1].Content, wantPreview) {
t.Fatalf("tool summary = %q, want preview %q", resp.Messages[1].Content, wantPreview)
}
- if strings.Contains(resp.Messages[1].Content, argsJSON) {
- t.Fatalf("tool summary = %q, expected configured truncation", resp.Messages[1].Content)
+ wantArgsPreview := visibleAssistantToolArgsPreview(providers.ToolCall{
+ Function: &providers.FunctionCall{Arguments: argsJSON},
+ }, 20)
+ if !strings.Contains(resp.Messages[1].Content, wantArgsPreview) {
+ t.Fatalf("tool summary = %q, want args preview %q", resp.Messages[1].Content, wantArgsPreview)
}
if !strings.Contains(resp.Messages[1].Content, "`read_file`") {
t.Fatalf("tool summary = %q, want read_file summary", resp.Messages[1].Content)
@@ -1139,7 +1142,9 @@ func TestHandleGetSession_FallsBackToLegacyToolArgumentsWhenExplanationMissing(t
t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages))
}
- wantPreview := utils.Truncate(argsJSON, 20)
+ wantPreview := visibleAssistantToolArgsPreview(providers.ToolCall{
+ Function: &providers.FunctionCall{Arguments: argsJSON},
+ }, 20)
if !strings.Contains(resp.Messages[1].Content, "`read_file`") {
t.Fatalf("tool summary = %q, want read_file summary", resp.Messages[1].Content)
}
diff --git a/web/backend/api/tools_test.go b/web/backend/api/tools_test.go
index ffeae9b64..c98067e41 100644
--- a/web/backend/api/tools_test.go
+++ b/web/backend/api/tools_test.go
@@ -9,7 +9,6 @@ import (
"testing"
"github.com/sipeed/picoclaw/pkg/config"
- picotools "github.com/sipeed/picoclaw/pkg/tools"
)
func TestHandleListTools(t *testing.T) {
@@ -517,22 +516,12 @@ func TestResolveCurrentWebSearchProvider_FallsBackWhenProviderIsUnknown(t *testi
}
}
-func TestResolveCurrentWebSearchProvider_UsesPreferredLanguageForSogouAndDuckDuckGo(t *testing.T) {
+func TestResolveCurrentWebSearchProvider_PrefersStableDefaultForSogouAndDuckDuckGo(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Web.Provider = "auto"
cfg.Tools.Web.Sogou.Enabled = true
cfg.Tools.Web.DuckDuckGo.Enabled = true
- picotools.SetPreferredWebSearchLanguage("en")
- t.Cleanup(func() {
- picotools.SetPreferredWebSearchLanguage("")
- })
-
- if got := resolveCurrentWebSearchProvider(cfg); got != "duckduckgo" {
- t.Fatalf("resolveCurrentWebSearchProvider() = %q, want duckduckgo", got)
- }
-
- picotools.SetPreferredWebSearchLanguage("zh")
if got := resolveCurrentWebSearchProvider(cfg); got != "sogou" {
t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got)
}
diff --git a/web/backend/api/ui.go b/web/backend/api/ui.go
deleted file mode 100644
index 90d96403e..000000000
--- a/web/backend/api/ui.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package api
-
-import (
- "encoding/json"
- "net/http"
-
- "github.com/sipeed/picoclaw/pkg/tools"
-)
-
-type uiLanguageRequest struct {
- Language string `json:"language"`
-}
-
-func (h *Handler) registerUIRoutes(mux *http.ServeMux) {
- mux.HandleFunc("POST /api/ui/language", h.handleSetUILanguage)
-}
-
-func (h *Handler) handleSetUILanguage(w http.ResponseWriter, r *http.Request) {
- var req uiLanguageRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- http.Error(w, "invalid request body", http.StatusBadRequest)
- return
- }
-
- tools.SetPreferredWebSearchLanguage(req.Language)
- w.WriteHeader(http.StatusNoContent)
-}
diff --git a/web/backend/api/ui_test.go b/web/backend/api/ui_test.go
deleted file mode 100644
index 3de35b7cb..000000000
--- a/web/backend/api/ui_test.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package api
-
-import (
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-
- "github.com/sipeed/picoclaw/pkg/tools"
-)
-
-func TestHandleSetUILanguage(t *testing.T) {
- tools.SetPreferredWebSearchLanguage("")
- t.Cleanup(func() {
- tools.SetPreferredWebSearchLanguage("")
- })
-
- h := NewHandler("")
- mux := http.NewServeMux()
- h.RegisterRoutes(mux)
-
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/ui/language", strings.NewReader(`{"language":"zh"}`))
- req.Header.Set("Content-Type", "application/json")
- mux.ServeHTTP(rec, req)
-
- if rec.Code != http.StatusNoContent {
- t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusNoContent, rec.Body.String())
- }
- if got := tools.GetPreferredWebSearchLanguage(); got != "zh" {
- t.Fatalf("preferred web search language = %q, want zh", got)
- }
-}
-
-func TestHandleSetUILanguage_RejectsInvalidJSON(t *testing.T) {
- h := NewHandler("")
- mux := http.NewServeMux()
- h.RegisterRoutes(mux)
-
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/ui/language", strings.NewReader(`{`))
- req.Header.Set("Content-Type", "application/json")
- mux.ServeHTTP(rec, req)
-
- if rec.Code != http.StatusBadRequest {
- t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
- }
-}
diff --git a/web/backend/main.go b/web/backend/main.go
index f5362174b..fa2448d5c 100644
--- a/web/backend/main.go
+++ b/web/backend/main.go
@@ -29,7 +29,6 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/netbind"
- "github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/web/backend/api"
"github.com/sipeed/picoclaw/web/backend/dashboardauth"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
@@ -409,7 +408,6 @@ func main() {
if *lang != "" {
SetLanguage(*lang)
}
- tools.SetPreferredWebSearchLanguage(string(GetLanguage()))
// Resolve config path
configPath := utils.GetDefaultConfigPath()
diff --git a/web/frontend/package.json b/web/frontend/package.json
index 835682617..ab07b40a2 100644
--- a/web/frontend/package.json
+++ b/web/frontend/package.json
@@ -27,13 +27,13 @@
"clsx": "^2.1.1",
"dayjs": "^1.11.20",
"highlight.js": "^11.11.1",
- "i18next": "^26.0.3",
+ "i18next": "^26.0.7",
"i18next-browser-languagedetector": "^8.2.1",
"jotai": "^2.19.1",
"radix-ui": "^1.4.3",
"react": "19.2.5",
"react-dom": "19.2.5",
- "react-i18next": "^17.0.3",
+ "react-i18next": "^17.0.4",
"react-markdown": "^10.1.0",
"react-textarea-autosize": "^8.5.9",
"rehype-highlight": "^7.0.2",
@@ -65,7 +65,7 @@
"prettier": "^3.8.3",
"prettier-plugin-tailwindcss": "^0.7.2",
"typescript": "~5.9.3",
- "typescript-eslint": "^8.58.2",
- "vite": "^8.0.8"
+ "typescript-eslint": "^8.59.0",
+ "vite": "^8.0.10"
}
}
diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml
index 210c111c5..cb5ca18de 100644
--- a/web/frontend/pnpm-lock.yaml
+++ b/web/frontend/pnpm-lock.yaml
@@ -16,7 +16,7 @@ importers:
version: 3.41.1(react@19.2.5)
'@tailwindcss/vite':
specifier: ^4.2.2
- version: 4.2.2(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))
+ version: 4.2.2(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))
'@tanstack/react-query':
specifier: ^5.99.0
version: 5.99.0(react@19.2.5)
@@ -39,8 +39,8 @@ importers:
specifier: ^11.11.1
version: 11.11.1
i18next:
- specifier: ^26.0.3
- version: 26.0.3(typescript@5.9.3)
+ specifier: ^26.0.7
+ version: 26.0.7(typescript@5.9.3)
i18next-browser-languagedetector:
specifier: ^8.2.1
version: 8.2.1
@@ -57,8 +57,8 @@ importers:
specifier: 19.2.5
version: 19.2.5(react@19.2.5)
react-i18next:
- specifier: ^17.0.3
- version: 17.0.3(i18next@26.0.3(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3)
+ specifier: ^17.0.4
+ version: 17.0.4(i18next@26.0.7(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.5)
@@ -104,7 +104,7 @@ importers:
version: 0.5.19(tailwindcss@4.2.2)
'@tanstack/router-plugin':
specifier: ^1.164.0
- version: 1.167.9(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))
+ version: 1.167.9(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))
'@trivago/prettier-plugin-sort-imports':
specifier: ^6.0.2
version: 6.0.2(prettier@3.8.3)
@@ -119,10 +119,10 @@ importers:
version: 19.2.3(@types/react@19.2.14)
'@typescript-eslint/eslint-plugin':
specifier: ^8.58.2
- version: 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
+ version: 8.58.2(@typescript-eslint/parser@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
'@vitejs/plugin-react':
specifier: ^6.0.1
- version: 6.0.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))
+ version: 6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))
eslint:
specifier: ^10.2.1
version: 10.2.1(jiti@2.6.1)
@@ -148,11 +148,11 @@ importers:
specifier: ~5.9.3
version: 5.9.3
typescript-eslint:
- specifier: ^8.58.2
- version: 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
+ specifier: ^8.59.0
+ version: 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
vite:
- specifier: ^8.0.8
- version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
+ specifier: ^8.0.10
+ version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
packages:
@@ -299,11 +299,11 @@ packages:
peerDependencies:
'@noble/ciphers': ^1.0.0
- '@emnapi/core@1.9.2':
- resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
+ '@emnapi/core@1.10.0':
+ resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
- '@emnapi/runtime@1.9.2':
- resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==}
+ '@emnapi/runtime@1.10.0':
+ resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
'@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
@@ -608,8 +608,8 @@ packages:
resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==}
engines: {node: '>=18'}
- '@napi-rs/wasm-runtime@1.1.3':
- resolution: {integrity: sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==}
+ '@napi-rs/wasm-runtime@1.1.4':
+ resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
peerDependencies:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
@@ -650,8 +650,8 @@ packages:
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
- '@oxc-project/types@0.124.0':
- resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==}
+ '@oxc-project/types@0.127.0':
+ resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==}
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -1343,103 +1343,103 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
- '@rolldown/binding-android-arm64@1.0.0-rc.15':
- resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==}
+ '@rolldown/binding-android-arm64@1.0.0-rc.17':
+ resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
- '@rolldown/binding-darwin-arm64@1.0.0-rc.15':
- resolution: {integrity: sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==}
+ '@rolldown/binding-darwin-arm64@1.0.0-rc.17':
+ resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
- '@rolldown/binding-darwin-x64@1.0.0-rc.15':
- resolution: {integrity: sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==}
+ '@rolldown/binding-darwin-x64@1.0.0-rc.17':
+ resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
- '@rolldown/binding-freebsd-x64@1.0.0-rc.15':
- resolution: {integrity: sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==}
+ '@rolldown/binding-freebsd-x64@1.0.0-rc.17':
+ resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
- '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15':
- resolution: {integrity: sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==}
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17':
+ resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
- '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15':
- resolution: {integrity: sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==}
+ '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17':
+ resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15':
- resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==}
+ '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
+ resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15':
- resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==}
+ '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
+ resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
- '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15':
- resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==}
+ '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
+ resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
- '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15':
- resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==}
+ '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
+ resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@rolldown/binding-linux-x64-musl@1.0.0-rc.15':
- resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==}
+ '@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
+ resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
- '@rolldown/binding-openharmony-arm64@1.0.0-rc.15':
- resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==}
+ '@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
+ resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
- '@rolldown/binding-wasm32-wasi@1.0.0-rc.15':
- resolution: {integrity: sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==}
- engines: {node: '>=14.0.0'}
+ '@rolldown/binding-wasm32-wasi@1.0.0-rc.17':
+ resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
cpu: [wasm32]
- '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15':
- resolution: {integrity: sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==}
+ '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17':
+ resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
- '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15':
- resolution: {integrity: sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==}
+ '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17':
+ resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
- '@rolldown/pluginutils@1.0.0-rc.15':
- resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==}
+ '@rolldown/pluginutils@1.0.0-rc.17':
+ resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==}
'@rolldown/pluginutils@1.0.0-rc.7':
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
@@ -1736,8 +1736,16 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/parser@8.58.2':
- resolution: {integrity: sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==}
+ '@typescript-eslint/eslint-plugin@8.59.0':
+ resolution: {integrity: sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ '@typescript-eslint/parser': ^8.59.0
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/parser@8.59.0':
+ resolution: {integrity: sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@@ -1749,16 +1757,32 @@ packages:
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
+ '@typescript-eslint/project-service@8.59.0':
+ resolution: {integrity: sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
'@typescript-eslint/scope-manager@8.58.2':
resolution: {integrity: sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@typescript-eslint/scope-manager@8.59.0':
+ resolution: {integrity: sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@typescript-eslint/tsconfig-utils@8.58.2':
resolution: {integrity: sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
+ '@typescript-eslint/tsconfig-utils@8.59.0':
+ resolution: {integrity: sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
'@typescript-eslint/type-utils@8.58.2':
resolution: {integrity: sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1766,16 +1790,33 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
+ '@typescript-eslint/type-utils@8.59.0':
+ resolution: {integrity: sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
'@typescript-eslint/types@8.58.2':
resolution: {integrity: sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@typescript-eslint/types@8.59.0':
+ resolution: {integrity: sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@typescript-eslint/typescript-estree@8.58.2':
resolution: {integrity: sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
+ '@typescript-eslint/typescript-estree@8.59.0':
+ resolution: {integrity: sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
'@typescript-eslint/utils@8.58.2':
resolution: {integrity: sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1783,10 +1824,21 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
+ '@typescript-eslint/utils@8.59.0':
+ resolution: {integrity: sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
'@typescript-eslint/visitor-keys@8.58.2':
resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@typescript-eslint/visitor-keys@8.59.0':
+ resolution: {integrity: sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
@@ -2544,8 +2596,8 @@ packages:
i18next-browser-languagedetector@8.2.1:
resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==}
- i18next@26.0.3:
- resolution: {integrity: sha512-1571kXINxHKY7LksWp8wP+zP0YqHSSpl/OW0Y0owFEf2H3s8gCAffWaZivcz14rMkOvn3R/psiQxVsR9t2Nafg==}
+ i18next@26.0.7:
+ resolution: {integrity: sha512-f7tL/iw0VQsx4nC5oNxBM2RjM8alNys5KzyiQTU6A9TI5TI89py4/Ez1cKFvHiLWsvzOXvuGUES+Kk/A2WiANQ==}
peerDependencies:
typescript: ^5 || ^6
peerDependenciesMeta:
@@ -3233,10 +3285,6 @@ packages:
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
engines: {node: ^10 || ^12 || >=14}
- postcss@8.5.9:
- resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
- engines: {node: ^10 || ^12 || >=14}
-
powershell-utils@0.1.0:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
@@ -3357,8 +3405,8 @@ packages:
peerDependencies:
react: ^19.2.5
- react-i18next@17.0.3:
- resolution: {integrity: sha512-x4xjvUNZ56T+zfXWNedNnCET9Xq1IBYWX7IsWo5cCQ/RT+Rm7GWqt0h9PShFi4IhyMnsdiu1C6Jc4DE+/S3PFQ==}
+ react-i18next@17.0.4:
+ resolution: {integrity: sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==}
peerDependencies:
i18next: '>= 26.0.1'
react: '>= 16.8.0'
@@ -3474,8 +3522,8 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
- rolldown@1.0.0-rc.15:
- resolution: {integrity: sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==}
+ rolldown@1.0.0-rc.17:
+ resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -3736,8 +3784,8 @@ packages:
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
engines: {node: '>= 0.6'}
- typescript-eslint@8.58.2:
- resolution: {integrity: sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==}
+ typescript-eslint@8.59.0:
+ resolution: {integrity: sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@@ -3872,8 +3920,8 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
- vite@8.0.8:
- resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==}
+ vite@8.0.10:
+ resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -4212,13 +4260,13 @@ snapshots:
dependencies:
'@noble/ciphers': 1.3.0
- '@emnapi/core@1.9.2':
+ '@emnapi/core@1.10.0':
dependencies:
'@emnapi/wasi-threads': 1.2.1
tslib: 2.8.1
optional: true
- '@emnapi/runtime@1.9.2':
+ '@emnapi/runtime@1.10.0':
dependencies:
tslib: 2.8.1
optional: true
@@ -4451,10 +4499,10 @@ snapshots:
outvariant: 1.4.3
strict-event-emitter: 0.5.1
- '@napi-rs/wasm-runtime@1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)':
+ '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
- '@emnapi/core': 1.9.2
- '@emnapi/runtime': 1.9.2
+ '@emnapi/core': 1.10.0
+ '@emnapi/runtime': 1.10.0
'@tybys/wasm-util': 0.10.1
optional: true
@@ -4489,7 +4537,7 @@ snapshots:
'@open-draft/until@2.1.0': {}
- '@oxc-project/types@0.124.0': {}
+ '@oxc-project/types@0.127.0': {}
'@radix-ui/number@1.1.1': {}
@@ -5238,56 +5286,56 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
- '@rolldown/binding-android-arm64@1.0.0-rc.15':
+ '@rolldown/binding-android-arm64@1.0.0-rc.17':
optional: true
- '@rolldown/binding-darwin-arm64@1.0.0-rc.15':
+ '@rolldown/binding-darwin-arm64@1.0.0-rc.17':
optional: true
- '@rolldown/binding-darwin-x64@1.0.0-rc.15':
+ '@rolldown/binding-darwin-x64@1.0.0-rc.17':
optional: true
- '@rolldown/binding-freebsd-x64@1.0.0-rc.15':
+ '@rolldown/binding-freebsd-x64@1.0.0-rc.17':
optional: true
- '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15':
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17':
optional: true
- '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15':
+ '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17':
optional: true
- '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15':
+ '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
optional: true
- '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15':
+ '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
optional: true
- '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15':
+ '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
optional: true
- '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15':
+ '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
optional: true
- '@rolldown/binding-linux-x64-musl@1.0.0-rc.15':
+ '@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
optional: true
- '@rolldown/binding-openharmony-arm64@1.0.0-rc.15':
+ '@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
optional: true
- '@rolldown/binding-wasm32-wasi@1.0.0-rc.15':
+ '@rolldown/binding-wasm32-wasi@1.0.0-rc.17':
dependencies:
- '@emnapi/core': 1.9.2
- '@emnapi/runtime': 1.9.2
- '@napi-rs/wasm-runtime': 1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
+ '@emnapi/core': 1.10.0
+ '@emnapi/runtime': 1.10.0
+ '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
optional: true
- '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15':
+ '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17':
optional: true
- '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15':
+ '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17':
optional: true
- '@rolldown/pluginutils@1.0.0-rc.15': {}
+ '@rolldown/pluginutils@1.0.0-rc.17': {}
'@rolldown/pluginutils@1.0.0-rc.7': {}
@@ -5368,12 +5416,12 @@ snapshots:
postcss-selector-parser: 6.0.10
tailwindcss: 4.2.2
- '@tailwindcss/vite@4.2.2(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))':
+ '@tailwindcss/vite@4.2.2(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))':
dependencies:
'@tailwindcss/node': 4.2.2
'@tailwindcss/oxide': 4.2.2
tailwindcss: 4.2.2
- vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
+ vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
'@tanstack/history@1.161.6': {}
@@ -5446,7 +5494,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))':
+ '@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0)
@@ -5463,7 +5511,7 @@ snapshots:
zod: 3.25.76
optionalDependencies:
'@tanstack/react-router': 1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
- vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
+ vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
transitivePeerDependencies:
- supports-color
@@ -5558,10 +5606,10 @@ snapshots:
'@types/validate-npm-package-name@4.0.2': {}
- '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.58.2
'@typescript-eslint/type-utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
@@ -5574,12 +5622,28 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/scope-manager': 8.58.2
- '@typescript-eslint/types': 8.58.2
- '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3)
- '@typescript-eslint/visitor-keys': 8.58.2
+ '@eslint-community/regexpp': 4.12.2
+ '@typescript-eslint/parser': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.59.0
+ '@typescript-eslint/type-utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.59.0
+ eslint: 10.2.1(jiti@2.6.1)
+ ignore: 7.0.5
+ natural-compare: 1.4.0
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/parser@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/scope-manager': 8.59.0
+ '@typescript-eslint/types': 8.59.0
+ '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.59.0
debug: 4.4.3
eslint: 10.2.1(jiti@2.6.1)
typescript: 5.9.3
@@ -5588,8 +5652,17 @@ snapshots:
'@typescript-eslint/project-service@8.58.2(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3)
- '@typescript-eslint/types': 8.58.2
+ '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.59.0
+ debug: 4.4.3
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/project-service@8.59.0(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.59.0
debug: 4.4.3
typescript: 5.9.3
transitivePeerDependencies:
@@ -5600,10 +5673,19 @@ snapshots:
'@typescript-eslint/types': 8.58.2
'@typescript-eslint/visitor-keys': 8.58.2
+ '@typescript-eslint/scope-manager@8.59.0':
+ dependencies:
+ '@typescript-eslint/types': 8.59.0
+ '@typescript-eslint/visitor-keys': 8.59.0
+
'@typescript-eslint/tsconfig-utils@8.58.2(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
+ '@typescript-eslint/tsconfig-utils@8.59.0(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
'@typescript-eslint/type-utils@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.58.2
@@ -5616,8 +5698,22 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/type-utils@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/types': 8.59.0
+ '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
+ debug: 4.4.3
+ eslint: 10.2.1(jiti@2.6.1)
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/types@8.58.2': {}
+ '@typescript-eslint/types@8.59.0': {}
+
'@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.58.2(typescript@5.9.3)
@@ -5633,6 +5729,21 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/typescript-estree@8.59.0(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/project-service': 8.59.0(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.59.0
+ '@typescript-eslint/visitor-keys': 8.59.0
+ debug: 4.4.3
+ minimatch: 10.2.5
+ semver: 7.7.4
+ tinyglobby: 0.2.16
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/utils@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1))
@@ -5644,17 +5755,33 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/utils@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1))
+ '@typescript-eslint/scope-manager': 8.59.0
+ '@typescript-eslint/types': 8.59.0
+ '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3)
+ eslint: 10.2.1(jiti@2.6.1)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/visitor-keys@8.58.2':
dependencies:
'@typescript-eslint/types': 8.58.2
eslint-visitor-keys: 5.0.1
+ '@typescript-eslint/visitor-keys@8.59.0':
+ dependencies:
+ '@typescript-eslint/types': 8.59.0
+ eslint-visitor-keys: 5.0.1
+
'@ungap/structured-clone@1.3.0': {}
- '@vitejs/plugin-react@6.0.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))':
+ '@vitejs/plugin-react@6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.7
- vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
+ vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
accepts@2.0.0:
dependencies:
@@ -6469,9 +6596,7 @@ snapshots:
dependencies:
'@babel/runtime': 7.29.2
- i18next@26.0.3(typescript@5.9.3):
- dependencies:
- '@babel/runtime': 7.29.2
+ i18next@26.0.7(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
@@ -7267,12 +7392,6 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
- postcss@8.5.9:
- dependencies:
- nanoid: 3.3.11
- picocolors: 1.1.1
- source-map-js: 1.2.1
-
powershell-utils@0.1.0: {}
prelude-ls@1.2.1: {}
@@ -7386,11 +7505,11 @@ snapshots:
react: 19.2.5
scheduler: 0.27.0
- react-i18next@17.0.3(i18next@26.0.3(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3):
+ react-i18next@17.0.4(i18next@26.0.7(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.29.2
html-parse-stringify: 3.0.1
- i18next: 26.0.3(typescript@5.9.3)
+ i18next: 26.0.7(typescript@5.9.3)
react: 19.2.5
use-sync-external-store: 1.6.0(react@19.2.5)
optionalDependencies:
@@ -7535,26 +7654,26 @@ snapshots:
reusify@1.1.0: {}
- rolldown@1.0.0-rc.15:
+ rolldown@1.0.0-rc.17:
dependencies:
- '@oxc-project/types': 0.124.0
- '@rolldown/pluginutils': 1.0.0-rc.15
+ '@oxc-project/types': 0.127.0
+ '@rolldown/pluginutils': 1.0.0-rc.17
optionalDependencies:
- '@rolldown/binding-android-arm64': 1.0.0-rc.15
- '@rolldown/binding-darwin-arm64': 1.0.0-rc.15
- '@rolldown/binding-darwin-x64': 1.0.0-rc.15
- '@rolldown/binding-freebsd-x64': 1.0.0-rc.15
- '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.15
- '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.15
- '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.15
- '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.15
- '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.15
- '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.15
- '@rolldown/binding-linux-x64-musl': 1.0.0-rc.15
- '@rolldown/binding-openharmony-arm64': 1.0.0-rc.15
- '@rolldown/binding-wasm32-wasi': 1.0.0-rc.15
- '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.15
- '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.15
+ '@rolldown/binding-android-arm64': 1.0.0-rc.17
+ '@rolldown/binding-darwin-arm64': 1.0.0-rc.17
+ '@rolldown/binding-darwin-x64': 1.0.0-rc.17
+ '@rolldown/binding-freebsd-x64': 1.0.0-rc.17
+ '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17
+ '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17
+ '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17
+ '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17
+ '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17
+ '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17
+ '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17
+ '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17
+ '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17
+ '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17
+ '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17
router@2.2.0:
dependencies:
@@ -7848,12 +7967,12 @@ snapshots:
media-typer: 1.1.0
mime-types: 3.0.2
- typescript-eslint@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3):
+ typescript-eslint@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/parser': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3)
- '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/eslint-plugin': 8.59.0(@typescript-eslint/parser@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
eslint: 10.2.1(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
@@ -7985,12 +8104,12 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
- vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0):
+ vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
- postcss: 8.5.9
- rolldown: 1.0.0-rc.15
+ postcss: 8.5.10
+ rolldown: 1.0.0-rc.17
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 25.6.0
diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx
index c09f5a06d..814ddc2f9 100644
--- a/web/frontend/src/components/chat/assistant-message.tsx
+++ b/web/frontend/src/components/chat/assistant-message.tsx
@@ -6,7 +6,6 @@ import {
IconDownload,
IconFileText,
} from "@tabler/icons-react"
-import { useAtom } from "jotai"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import ReactMarkdown from "react-markdown"
@@ -18,7 +17,7 @@ import remarkGfm from "remark-gfm"
import { Button } from "@/components/ui/button"
import { formatMessageTime } from "@/hooks/use-pico-chat"
import { cn } from "@/lib/utils"
-import { type ChatAttachment, showThoughtsAtom } from "@/store/chat"
+import { type ChatAttachment } from "@/store/chat"
interface AssistantMessageProps {
content: string
@@ -42,7 +41,7 @@ export function AssistantMessage({
const fileAttachments = attachments.filter(
(attachment) => attachment.type !== "image",
)
- const [isExpanded, setIsExpanded] = useAtom(showThoughtsAtom)
+ const [isExpanded, setIsExpanded] = useState(true)
const formattedTimestamp =
timestamp !== "" ? formatMessageTime(timestamp) : ""
diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx
index f50503dec..cc1a4624e 100644
--- a/web/frontend/src/components/config/config-page.tsx
+++ b/web/frontend/src/components/config/config-page.tsx
@@ -244,6 +244,7 @@ export function ConfigPage() {
tool_feedback: {
enabled: form.toolFeedbackEnabled,
max_args_length: toolFeedbackMaxArgsLength,
+ separate_messages: form.toolFeedbackSeparateMessages,
},
max_tokens: maxTokens,
context_window: contextWindow,
diff --git a/web/frontend/src/components/config/config-sections.tsx b/web/frontend/src/components/config/config-sections.tsx
index 25c335ab1..fa6b3a079 100644
--- a/web/frontend/src/components/config/config-sections.tsx
+++ b/web/frontend/src/components/config/config-sections.tsx
@@ -113,6 +113,18 @@ export function AgentDefaultsSection({
}
/>
+ {form.toolFeedbackEnabled && (
+
+ onFieldChange("toolFeedbackSeparateMessages", checked)
+ }
+ />
+ )}
+
{form.toolFeedbackEnabled && (
{
} else {
dayjs.locale("en")
}
-
- void launcherFetch("/api/ui/language", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ language: lng }),
- }).catch(() => {
- // Keep UI language changes responsive even if backend sync fails.
- })
})
export default i18n
diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json
index d25a3cea2..d188bb1d0 100644
--- a/web/frontend/src/i18n/locales/en.json
+++ b/web/frontend/src/i18n/locales/en.json
@@ -606,6 +606,8 @@
"split_on_marker_hint": "Split long messages into short ones like real human chatting.",
"tool_feedback_enabled": "Tool Feedback",
"tool_feedback_enabled_hint": "Send a short execution note into the current chat before each tool runs.",
+ "tool_feedback_separate_messages": "Separate Feedback Messages",
+ "tool_feedback_separate_messages_hint": "Keep each tool feedback update as its own chat message instead of reusing a single placeholder/progress message.",
"tool_feedback_max_args_length": "Tool Feedback Length",
"tool_feedback_max_args_length_hint": "Maximum number of characters shown in each tool feedback message. Set to 0 to use the default.",
"exec_enabled": "Allow Commands",
diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json
index 6b5d14d59..fcefdb01b 100644
--- a/web/frontend/src/i18n/locales/zh.json
+++ b/web/frontend/src/i18n/locales/zh.json
@@ -606,6 +606,8 @@
"split_on_marker_hint": "像真人聊天一样,把长难句拆成多条短消息快速发出",
"tool_feedback_enabled": "工具反馈",
"tool_feedback_enabled_hint": "在每次执行工具前,先向当前会话发送一条简短的执行说明",
+ "tool_feedback_separate_messages": "分开发送反馈消息",
+ "tool_feedback_separate_messages_hint": "让每次工具反馈都保留为独立消息,而不是反复复用同一条占位/进度消息",
"tool_feedback_max_args_length": "工具反馈长度",
"tool_feedback_max_args_length_hint": "每条工具反馈消息中展示的字符上限。设为 0 时使用默认值",
"exec_enabled": "允许命令执行",
diff --git a/web/frontend/src/store/chat.ts b/web/frontend/src/store/chat.ts
index 393254416..21d6ad709 100644
--- a/web/frontend/src/store/chat.ts
+++ b/web/frontend/src/store/chat.ts
@@ -57,8 +57,6 @@ const DEFAULT_CHAT_STATE: ChatStoreState = {
export const chatAtom = atom(DEFAULT_CHAT_STATE)
-export const showThoughtsAtom = atom(true)
-
const store = getDefaultStore()
export function getChatState() {