Final resolution of all merge conflicts and config cleanup

This commit is contained in:
stevef 2026-04-03 21:46:27 +02:00
commit f53e50c96a
22 changed files with 1685 additions and 52 deletions

View file

@ -2,7 +2,7 @@
.gitignore
build/
.picoclaw/
config/
# config/
.env
.env.example
*.md

View file

@ -0,0 +1,363 @@
# Teams Channel Integration & ID Mapping Analysis
## Executive Summary
**Teams Channel Implementation Status**: ❌ **NOT YET IMPLEMENTED**
- Search results show no Teams/MSTeams channel in `pkg/channels/`
- Only reference found: migration config reference in `pkg/migrate/sources/openclaw/openclaw_config.go:123`
- **Foundry Integration**: Only implemented as an LLM **provider** (Azure AI Foundry), not as a channel
---
## InboundMessage Structure (Bus Layer)
**Location**: [pkg/bus/types.go](pkg/bus/types.go)
### Core Fields Available
```go
type InboundMessage struct {
Channel string // Channel name (e.g., "teams", "slack", "telegram")
SenderID string // Platform-specific sender identifier
Sender SenderInfo // Structured sender information
ChatID string // Conversation/chat identifier (CRITICAL FOR ISOLATION)
Content string // Message text content
Media []string // Media references (attachments)
Peer Peer // Routing peer information
MessageID string // Platform-specific message ID
MediaScope string // Media lifecycle tracking scope
SessionKey string // Session key (optional, can be auto-resolved)
Metadata map[string]string // Platform-specific metadata
}
```
### SenderInfo Sub-structure
```go
type SenderInfo struct {
Platform string // "telegram", "discord", "slack", "teams", etc.
PlatformID string // Raw platform ID (e.g., Teams UserID "29:...")
CanonicalID string // Normalized "platform:id" format (e.g., "teams:29:...")
Username string // Display username (e.g., "@alice")
DisplayName string // Full display name
}
```
### Peer Sub-structure
```go
type Peer struct {
Kind string // "direct" | "group" | "channel" | ""
ID string // Peer identifier (user_id, group_id, channel_id, etc.)
}
```
---
## ID Mapping for Hypothetical Teams Implementation
### What Teams Would Need to Provide
If Teams were to be integrated, the following IDs should map as follows:
| Teams ID | InboundMessage Field | Notes |
|----------|----------------------|-------|
| User ID (e.g., `29:1ABC123`) | `SenderID`, `Sender.PlatformID` | Teams uses format `29:uuid` |
| Conversation ID | `ChatID` | CRITICAL: Identifies conversation scope |
| Team ID | `Metadata["team_id"]`, potentially routing input | Can be used for team-level routing |
| Channel ID | `Peer.ID` (if channel) | When in Team channel |
| Service URL | `Metadata["service_url"]` | Teams service endpoint |
| Activity ID | `MessageID` | Platform message identifier |
### Canonical ID Format
**Pattern**: `platform:platform_id`
**Example for Teams**:
```
"teams:29:1ABC123" = Canonical ID for Teams user 29:1ABC123
```
Built via: [pkg/identity/identity.go](pkg/identity/identity.go)
```go
func BuildCanonicalID(platform, platformID string) string {
p := strings.ToLower(strings.TrimSpace(platform))
id := strings.TrimSpace(platformID)
if p == "" || id == "" {
return ""
}
return p + ":" + id // "teams:29:abc123"
}
```
---
## ChatID Usage & Session Isolation
**Location**: [pkg/agent/loop.go](pkg/agent/loop.go#L1250-L1270)
### Current ChatID Role
The `ChatID` field is **THE PRIMARY KEY** for conversation isolation:
1. **Session Binding**: Each unique `ChatID` can map to a separate session depending on DMScope
2. **Workspace Isolation**: When non-empty and not "direct", creates isolated agent workspace:
```go
if isolationID != "" && isolationID != "direct" {
// Create transient isolated instance for this chat session
agent = NewAgentInstance(ac, cfg, baseAgent.Provider, isolationID)
}
```
3. **State Persistence**: Last ChatID tracked for workspace continuity
**Example mapping**:
- Single direct message with user → `ChatID = "teams:29:1ABC123"`
- Team channel conversation → `ChatID = "teams-channel:xyz789"`
- Group chat → `ChatID = "teams-groupchat:123abc"`
---
## Session Key Construction & Resolution
**Location**: [pkg/routing/session_key.go](pkg/routing/session_key.go) + [pkg/routing/route.go](pkg/routing/route.go)
### RouteInput (What Channel Provides to Router)
```go
type RouteInput struct {
Channel string // "teams" (if implemented)
AccountID string // Bot account/app ID
Peer *RoutePeer // Who message is from (user)
ParentPeer *RoutePeer // Parent context (e.g., Team)
GuildID string // Guild/workspace ID (if applicable)
TeamID string // Teams Team ID (would go here)
}
```
### ResolvedRoute Output
```go
type ResolvedRoute struct {
AgentID string // Which agent handles this message
SessionKey string // Session identifier pattern
MainSessionKey string // Main session fallback
MatchedBy string // How routing was matched
}
```
### Session Key Patterns
**DMScope** configuration determines how sessions are keyed:
| DMScope Mode | Format | Example | Use Case |
|--------------|--------|---------|----------|
| `DMScopeMain` | `agent:agentid:main` | `agent:teams-bot:main` | Single shared session |
| `DMScopePerPeer` | `agent:agentid:direct:peerid` | `agent:teams-bot:direct:user123` | Per-user sessions |
| `DMScopePerChannelPeer` | `agent:agentid:channel:direct:peerid` | `agent:teams-bot:teams:direct:user123` | Per-channel-per-user |
| `DMScopePerAccountChannelPeer` | `agent:agentid:channel:account:direct:peerid` | `agent:teams-bot:teams:acct1:direct:user123` | Per-account-channel-user |
**Location**: [pkg/routing/session_key.go:40-100](pkg/routing/session_key.go#L40-L100)
```go
// For Teams direct message:
BuildAgentPeerSessionKey(SessionKeyParams{
AgentID: "teams-bot",
Channel: "teams",
AccountID: "bot-app-id",
Peer: &RoutePeer{Kind: "direct", ID: "29:abc123"},
DMScope: DMScopePerChannelPeer,
})
// Returns: "agent:teams-bot:teams:direct:29:abc123"
```
---
## ID Priority Cascade for Agent Routing
**Location**: [pkg/routing/route.go:68-126](pkg/routing/route.go#L68-L126)
The agent resolver uses this **7-level priority**:
1. **Peer binding** → Match on specific user/peer ID
2. **Parent peer binding** → Match on parent context (Team, Guild, etc.)
3. **Guild binding** → Match on Guild/Workspace ID
4. **Team binding** → Match on Team ID ← **TEAMS WOULD USE THIS**
5. **Account binding** → Match on account/app ID
6. **Channel wildcard** → Match on channel with wildcard
7. **Default agent** → Fallback
**For Teams, routing would likely use**:
- Level 2: ParentPeer = Team
- Level 3: GuildID = Team ID
- Level 4: TeamID = Team ID
---
## Foundry Integration Status
**Locations**:
- [pkg/providers/factory_provider.go:196](pkg/providers/factory_provider.go#L196)
- [pkg/providers/openai_compat/provider.go:432](pkg/providers/openai_compat/provider.go#L432)
### Current Foundry Support
**Type**: LLM **Provider Only** (NOT Channel)
```go
case "azure-ai", "azure-foundry":
// Azure AI Foundry / Studio compatible with OpenAI API format
// Used for LLM backend, not message channeling
```
**What's Missing for Teams/Foundry Integration**:
- ❌ No Teams Channel handler
- ❌ No Foundry Agent channel integration
- ❌ No Teams webhook receiver
- ❌ No Teams message routing
**What Exists**:
- ✅ Azure AI Foundry as LLM provider backend
- ✅ OpenAI-compatible API handling
- ✅ Generic inbound message bus infrastructure
---
## Metadata Field Usage
All channels populate `InboundMessage.Metadata` with platform-specific data:
### Example: WeCom (for comparison)
**Location**: [pkg/channels/wecom/app.go:605-620](pkg/channels/wecom/app.go#L605-L620)
```go
metadata := map[string]string{
"msg_type": msg.MsgType,
"msg_id": fmt.Sprintf("%d", msg.MsgId),
"agent_id": fmt.Sprintf("%d", msg.AgentID),
"platform": "wecom",
"media_id": msg.MediaId,
"create_time": fmt.Sprintf("%d", msg.CreateTime),
}
```
### For Teams Implementation, Would Include:
```go
metadata := map[string]string{
"team_id": msg.TeamsTeamID,
"channel_id": msg.TeamsChannelID,
"service_url": msg.ServiceURL,
"activity_id": msg.ActivityID,
"conversation_id": msg.ConversationID,
"from_user_id": msg.FromUserID,
"platform": "teams",
...
}
```
---
## Identity Matching System
**Location**: [pkg/identity/identity.go](pkg/identity/identity.go)
The framework provides legacy-compatible and modern identity matching:
### Allowed Formats in Config
```yaml
allow_from:
- "29:abc123" # Raw Teams user ID
- "teams:29:abc123" # Canonical format
- "@alice" # Username format
- "29:abc123|alice" # Compound format
```
### Matching Logic
```go
func MatchAllowed(sender bus.SenderInfo, allowed string) bool {
// 1. Try canonical "platform:id" first
if platform, id, ok := ParseCanonicalID(allowed); ok {
if sender.CanonicalID == BuildCanonicalID(platform, id) {
return true
}
}
// 2. Fall back to PlatformID or Username
if sender.PlatformID == allowed { return true }
if sender.Username == "@" + allowed { return true }
return false
}
```
---
## What a Teams Channel Implementation Would Need
### Minimum Required Fields in InboundMessage
```go
InboundMessage{
Channel: "teams",
SenderID: userID, // Teams: "29:uuid"
Sender: bus.SenderInfo{
Platform: "teams",
PlatformID: userID, // "29:uuid"
CanonicalID: "teams:29:uuid",
Username: userName,
DisplayName: displayName,
},
ChatID: conversationID, // Teams ConversationReference.conversation_id
Content: messageContent,
Peer: bus.Peer{
Kind: "direct" || "channel",
ID: channelID || userID,
},
MessageID: activityID, // Teams Activity ID
Metadata: map[string]string{
"team_id": teamID,
"channel_id": channelID,
"service_url": serviceURL,
// ... other Teams-specific fields
},
}
```
### Routing Setup in Config
```yaml
agents:
routing:
- agent_id: "teams-agent"
match:
channel: "teams"
team_id: "team-xyz" # Route by Teams Team ID
```
---
## Key Takeaways for Teams + Foundry Integration
1. **Framework is Ready**: GenericBus message structure can handle Teams IDs
2. **ChatID is Primary**: Use Teams `ConversationReference.conversation_id` as ChatID for isolation
3. **SessionKey Auto-Generated**: Routing + DMScope automatically creates session keys
4. **Identity System Ready**: Canonical "teams:29:uuid" format supported
5. **No Channel Implementation Yet**: Need to implement webhook receiver + message publisher
6. **Foundry is Provider Only**: Currently only LLM backend, not messaging channel
7. **User ID Format**: Teams uses `29:uuid` format - should populate both PlatformID and CanonicalID
8. **Conversation Scope**: Teams conversation_id maps directly to InboundMessage.ChatID
---
## Reference Architecture Files
| Component | File | Key Types |
|-----------|------|-----------|
| Bus Types | [pkg/bus/types.go](pkg/bus/types.go) | InboundMessage, SenderInfo, Peer |
| Routing | [pkg/routing/route.go](pkg/routing/route.go) | RouteInput, ResolvedRoute |
| Session Keys | [pkg/routing/session_key.go](pkg/routing/session_key.go) | SessionKeyParams, DM scopes |
| Identity | [pkg/identity/identity.go](pkg/identity/identity.go) | BuildCanonicalID, MatchAllowed |
| Agent Loop | [pkg/agent/loop.go](pkg/agent/loop.go) | Message processing, session isolation |
| Example Channel | [pkg/channels/wecom/app.go](pkg/channels/wecom/app.go) | Channel implementation pattern |

315
TEAMS_QUICK_REFERENCE.md Normal file
View file

@ -0,0 +1,315 @@
# Quick Reference: Teams Integration Questions
## Q1: Teams Channel Integration - Message Receiving & Processing
**Status**: ❌ NOT IMPLEMENTED
**Where it would go**: `pkg/channels/teams/` (currently doesn't exist)
**Current Similar Implementation**: See [pkg/channels/wecom/app.go](pkg/channels/wecom/app.go) for webhook pattern
**Expected Pattern**:
1. HTTP webhook receiver on configured port
2. Verify Teams Bot Framework signature
3. Parse activity/message payload
4. Build `InboundMessage` struct
5. Publish to bus via `channel.HandleMessage()` or `messageBus.PublishInbound()`
**Key Files to Reference**:
- [pkg/channels/base.go](pkg/channels/base.go) - Base channel interface
- [pkg/channels/manager.go](pkg/channels/manager.go) - Channel registration/lifecycle
- [pkg/channels/wecom/app.go:605-650](pkg/channels/wecom/app.go#L605-L650) - HandleMessage pattern
---
## Q2: InboundMessage Structure - All Available Fields
**Location**: [pkg/bus/types.go:18-35](pkg/bus/types.go#L18-L35)
### Complete Field List
| Field | Type | Purpose | Example |
|-------|------|---------|---------|
| `Channel` | string | Platform identifier | `"teams"` |
| `SenderID` | string | Raw user ID | `"29:1ABC123"` |
| `Sender` | SenderInfo | Structured identity | (see below) |
| `Sender.Platform` | string | Platform name | `"teams"` |
| `Sender.PlatformID` | string | User platform ID | `"29:1ABC123"` |
| `Sender.CanonicalID` | string | **Normalized format** | `"teams:29:1abc123"` |
| `Sender.Username` | string | Handle/username | `"alice"` |
| `Sender.DisplayName` | string | Full display name | `"Alice Smith"` |
| `ChatID` | string | **Conversation ID (PRIMARY)** | `"teams-conv-abc123"` |
| `Content` | string | Message text | `"Hello world"` |
| `Media` | []string | Media references | `["media://ref123"]` |
| `Peer.Kind` | string | Peer type | `"direct"` \| `"channel"` |
| `Peer.ID` | string | Peer ID | User/channel ID |
| `MessageID` | string | Platform message ID | `"activity-123"` |
| `MediaScope` | string | Media cleanup scope | `"teams:conv-abc123:msg-123"` |
| `SessionKey` | string | **Session identifier** | `"agent:bot:teams:direct:29:abc123"` |
| `Metadata` | map | Platform-specific data | (see below) |
### Metadata Map (Platform-Specific)
```go
metadata := map[string]string{
"team_id": "T12345",
"channel_id": "C12345",
"conversation_id": "19:...",
"service_url": "https://smba.trafficmanager.net/...",
"activity_id": "...",
"from_user_id": "29:...",
"from_user_name": "alice",
"recipient_id": "28:...",
"conversation_type": "personal|groupChat|channel",
"platform": "teams",
// ... any other Teams-specific fields
}
```
---
## Q3: Unique User/Conversation ID Capture from Teams
### What Teams Provides vs. What PicoClaw Needs
**Teams → PicoClaw Mapping**:
```
Teams Activity Object
├── from.id → SenderID (raw), Sender.PlatformID
├── from.aadObjectId → (optional, use if available)
├── conversation.id → ChatID (THE KEY FIELD)
├── conversation.tenantId → Metadata["tenant_id"]
├── channelData.teamsChannelId → Peer.ID (if channel)
├── channelData.teamsTeamId → Metadata["team_id"], routing input
├── serviceUrl → Metadata["service_url"]
└── id → MessageID
```
### ID Construction
**User Identity Chain**:
```
Teams: from.id = "29:U123ABC"
Stored as: SenderID = "29:U123ABC"
Stored as: Sender.PlatformID = "29:U123ABC"
Normalized as: Sender.CanonicalID = "teams:29:u123abc" (lowercased)
```
**Conversation Identity Chain**:
```
Teams: conversation.id = "19:abc123@thread.v2"
Stored as: ChatID = "19:abc123@thread.v2" (conversation scope)
Used for: Session isolation, message routing, state persistence
```
**Team Identity Chain**:
```
Teams: channelData.teamsTeamId = "T12345678"
Stored as: Metadata["team_id"] = "T12345678"
Used in: Routing cascade (Level 4), agent selection
```
### Canonical ID Format
Built by [pkg/identity/identity.go:BuildCanonicalID()](pkg/identity/identity.go#L11-L20):
```go
BuildCanonicalID("teams", "29:U123ABC")
// Returns: "teams:29:u123abc" (normalized to lowercase)
```
**Used for**:
- Access control matching
- Cross-platform user linking (via identity_links in config)
- User identity validation
---
## Q4: Foundry Agent Integration Points & ID Provision
**Status**: ⚠️ PARTIAL - Foundry is an LLM Provider, NOT a Channel
### Current Foundry Support
**Location**: [pkg/providers/factory_provider.go:196](pkg/providers/factory_provider.go#L196)
Foundry is integrated **only as LLM backend** (OpenAI-compatible API):
```go
case "azure-ai", "azure_foundry":
// Use for model calls, not messaging
```
**What Foundry Would Provide (if implemented as channel)**:
- Foundry Agent service/conversation IDs
- Foundry user session tracking
- Foundry-specific message format
**What's MISSING**:
1. ❌ Foundry Agent channel receiver
2. ❌ Foundry conversation → ChatID mapping
3. ❌ Foundry agent ID → Agent routing
### If Foundry Channel Were to Exist
Expected `InboundMessage` would be:
```go
InboundMessage{
Channel: "foundry-agent",
SenderID: foundryUserID,
Sender: SenderInfo{
Platform: "foundry",
PlatformID: foundryUserID,
CanonicalID: "foundry:" + foundryUserID,
DisplayName: userName,
},
ChatID: foundryConversationID, // Critical for isolation
Content: message,
Metadata: map[string]string{
"foundry_agent_id": agentID,
"foundry_conversation_id": conversationID,
"foundry_message_id": messageID,
"platform": "foundry",
// ... other Foundry fields
},
}
```
### Foundry ID Mapping Table (Hypothetical)
| Foundry ID | InboundMessage Field | Purpose |
|----------|----------------------|---------|
| Agent ID | Routing/Config | Which agent handles |
| User ID | SenderID | Who sent message |
| Conversation ID | **ChatID** | Session isolation |
| Message ID | MessageID | For threading |
| Service Endpoint | Metadata | For API calls |
---
## Q5: How ChatID is Currently Used for Session ID Association
**Location**: [pkg/agent/loop.go:1248-1270](pkg/agent/loop.go#L1248-L1270)
### ChatID → SessionKey Conversion
**Process**:
```
1. InboundMessage arrives with ChatID
2. Router resolves agent (via RouteInput)
3. SessionKey built from:
- Agent ID
- Channel name
- Peer information (ChatID wrapped as Peer.ID)
- DMScope configuration
4. Result: SessionKey = "agent:botname:team:type:id"
5. SessionKey used to find/create workspace & history
```
### Session Key Patterns by DMScope
**From config `session.dm_scope`**:
| Setting | Session Behavior | Key Format |
|---------|------------------|-----------|
| Not set / `main` | Single shared session | `agent:bot:main` |
| `per_peer` | One session per user | `agent:bot:direct:user123` |
| `per_channel_peer` | One per channel+user | `agent:bot:teams:direct:user123` |
| `per_account_channel_peer` | One per account+channel+user | `agent:bot:teams:act1:direct:user123` |
**Code Reference**: [pkg/routing/session_key.go:40-100](pkg/routing/session_key.go#L40-L100)
### Session Isolation via ChatID
When ChatID is unique and non-"direct":
```go
// From pkg/agent/loop.go:1248-1270
if isolationID != "" && isolationID != "direct" {
// Creates isolated agent instance with separate:
// - Workspace directory
// - Session history
// - Memory storage
// - State
agent = NewAgentInstance(ac, cfg, baseAgent.Provider, isolationID)
}
```
**Isolation Example**:
```
ChatID = "teams-channel-abc123"
Creates: workspace/teams-channel-abc123/
├── sessions/
├── memory/
├── skills/
└── state/
Each channel conversation has completely isolated history
```
### State Persistence
Tracks last ChatID:
```go
// Record last chat for workspace continuity
al.RecordLastChatID(chatID) // pkg/agent/loop.go
```
Stored in: `workspace/state/state.json`:
```json
{
"last_channel": "teams",
"last_chat_id": "19:abc123@thread.v2",
"timestamp": "2025-03-26T10:00:00Z"
}
```
---
## Summary Table: ID Field Mapping
| Concept | Field | Example | Used For |
|---------|-------|---------|----------|
| **User** | `SenderID` + `Sender.PlatformID` | `"29:U123ABC"` | Message author |
| **User (Normalized)** | `Sender.CanonicalID` | `"teams:29:u123abc"` | Access control |
| **Conversation** | `ChatID` | `"19:abc123@thread.v2"` | **Session isolation** |
| **Team** | `Metadata["team_id"]` | `"T12345678"` | Agent routing level |
| **Channel** | `Peer.Kind` + `Peer.ID` | `"channel:C12345"` | Routing peer |
| **Message** | `MessageID` | `"activity-123"` | Threading, dedup |
| **Workspace** | Derived from ChatID | `workspace/19:abc123@thread.v2/` | Data isolation |
| **Session** | `SessionKey` | `"agent:bot:teams:direct:29:u123abc"` | History tracking |
---
## File Cross-References
### For Teams Implementation
- Start: [pkg/channels/manager.go](pkg/channels/manager.go) - Channel registration
- Reference: [pkg/channels/wecom/app.go](pkg/channels/wecom/app.go) - Full implementation pattern
- Base: [pkg/channels/base.go](pkg/channels/base.go) - Handler interface
### For Routing/Session
- Routing: [pkg/routing/route.go](pkg/routing/route.go) - 7-level cascade
- Keys: [pkg/routing/session_key.go](pkg/routing/session_key.go) - Key building
- Isolation: [pkg/agent/loop.go:1248+](pkg/agent/loop.go#L1248) - ChatID isolation
### For Identity
- Identity: [pkg/identity/identity.go](pkg/identity/identity.go) - CanonicalID logic
- Matching: Lines 28-100 - Access control matching
### For State
- State: [pkg/state/state.go](pkg/state/state.go) - LastChatID persistence

568
config/config.json.azure Normal file
View file

@ -0,0 +1,568 @@
{
"session": {
"dm_scope": "per-channel-peer"
},
"version": 1,
"agents": {
"defaults": {
"workspace": "",
"restrict_to_workspace": true,
"allow_read_outside_workspace": false,
"provider": "openai",
"model_name": "azure-grok",
"max_tokens": 32768,
"max_tool_iterations": 50,
"summarize_message_threshold": 20,
"summarize_token_percent": 75,
"steering_mode": "one-at-a-time",
"subturn": {
"max_depth": 10,
"max_concurrent": 5,
"default_timeout_minutes": 20,
"default_token_budget": 100000,
"concurrency_timeout_sec": 10
},
"tool_feedback": {
"enabled": true,
"max_args_length": 300
}
}
},
"channels": {
"whatsapp": {
"enabled": false,
"bridge_url": "ws://localhost:3001",
"use_native": false,
"session_store_path": "",
"allow_from": [],
"reasoning_channel_id": ""
},
"telegram": {
"enabled": false,
"base_url": "",
"proxy": "",
"allow_from": [],
"group_trigger": {},
"typing": {
"enabled": true
},
"placeholder": {
"enabled": true,
"text": "Thinking... 💭"
},
"streaming": {
"enabled": true,
"throttle_seconds": 3,
"min_growth_chars": 200
},
"reasoning_channel_id": "",
"use_markdown_v2": false
},
"feishu": {
"enabled": false,
"app_id": "",
"allow_from": [],
"group_trigger": {},
"placeholder": {},
"reasoning_channel_id": "",
"random_reaction_emoji": null,
"is_lark": false
},
"discord": {
"enabled": false,
"proxy": "",
"allow_from": [],
"mention_only": false,
"group_trigger": {},
"typing": {},
"placeholder": {},
"reasoning_channel_id": ""
},
"maixcam": {
"enabled": false,
"host": "0.0.0.0",
"port": 18790,
"allow_from": [],
"reasoning_channel_id": ""
},
"qq": {
"enabled": false,
"app_id": "",
"allow_from": [],
"group_trigger": {},
"max_message_length": 2000,
"max_base64_file_size_mib": 0,
"send_markdown": false,
"reasoning_channel_id": ""
},
"dingtalk": {
"enabled": false,
"client_id": "",
"allow_from": [],
"group_trigger": {},
"reasoning_channel_id": ""
},
"slack": {
"enabled": false,
"allow_from": [],
"group_trigger": {},
"typing": {},
"placeholder": {},
"reasoning_channel_id": ""
},
"matrix": {
"enabled": false,
"homeserver": "https://matrix.org",
"user_id": "",
"join_on_invite": true,
"allow_from": [],
"group_trigger": {
"mention_only": true
},
"placeholder": {
"enabled": true,
"text": "Thinking... 💭"
},
"reasoning_channel_id": ""
},
"line": {
"enabled": false,
"webhook_host": "0.0.0.0",
"webhook_port": 18791,
"webhook_path": "/webhook/line",
"allow_from": [],
"group_trigger": {
"mention_only": true
},
"typing": {},
"placeholder": {},
"reasoning_channel_id": ""
},
"onebot": {
"enabled": false,
"ws_url": "ws://127.0.0.1:3001",
"reconnect_interval": 5,
"group_trigger_prefix": null,
"allow_from": [],
"group_trigger": {},
"typing": {},
"placeholder": {},
"reasoning_channel_id": ""
},
"wecom": {
"enabled": false,
"webhook_url": "",
"webhook_host": "0.0.0.0",
"webhook_port": 18793,
"webhook_path": "/webhook/wecom",
"allow_from": [],
"reply_timeout": 5,
"group_trigger": {},
"reasoning_channel_id": ""
},
"wecom_app": {
"enabled": false,
"corp_id": "",
"agent_id": 0,
"webhook_host": "0.0.0.0",
"webhook_port": 18792,
"webhook_path": "/webhook/wecom-app",
"allow_from": [],
"reply_timeout": 5,
"group_trigger": {},
"reasoning_channel_id": ""
},
"wecom_aibot": {
"enabled": false,
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
"reply_timeout": 5,
"max_steps": 10,
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?",
"processing_message": "⏳ Processing, please wait. The results will be sent shortly.",
"reasoning_channel_id": ""
},
"weixin": {
"enabled": false,
"base_url": "https://ilinkai.weixin.qq.com/",
"cdn_base_url": "https://novac2c.cdn.weixin.qq.com/c2c",
"proxy": "",
"allow_from": [],
"reasoning_channel_id": ""
},
"pico": {
"enabled": false,
"ping_interval": 30,
"read_timeout": 60,
"write_timeout": 10,
"max_connections": 100,
"allow_from": [],
"placeholder": {}
},
"pico_client": {
"enabled": false,
"url": "",
"token": "",
"allow_from": null
},
"irc": {
"enabled": false,
"server": "",
"tls": false,
"nick": "",
"sasl_user": "",
"channels": null,
"allow_from": null,
"group_trigger": {},
"typing": {},
"reasoning_channel_id": ""
}
},
"model_list": [
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"api_base": "https://open.bigmodel.cn/api/paas/v4"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api.openai.com/v1"
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"api_base": "https://api.anthropic.com/v1"
},
{
"model_name": "deepseek-chat",
"model": "deepseek/deepseek-chat",
"api_base": "https://api.deepseek.com/v1"
},
{
"model_name": "gemini-2.0-flash",
"model": "gemini/gemini-2.0-flash-exp",
"api_base": "https://generativelanguage.googleapis.com/v1beta"
},
{
"model_name": "qwen-plus",
"model": "qwen/qwen-plus",
"api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1"
},
{
"model_name": "moonshot-v1-8k",
"model": "moonshot/moonshot-v1-8k",
"api_base": "https://api.moonshot.cn/v1"
},
{
"model_name": "llama-3.3-70b",
"model": "groq/llama-3.3-70b-versatile",
"api_base": "https://api.groq.com/openai/v1"
},
{
"model_name": "openrouter-auto",
"model": "openrouter/auto",
"api_base": "https://openrouter.ai/api/v1"
},
{
"model_name": "openrouter-gpt-5.4",
"model": "openrouter/openai/gpt-5.4",
"api_base": "https://openrouter.ai/api/v1"
},
{
"model_name": "nemotron-4-340b",
"model": "nvidia/nemotron-4-340b-instruct",
"api_base": "https://integrate.api.nvidia.com/v1"
},
{
"model_name": "azure-grok",
"model": "openai/grok-4-fast-non-reasoning",
"api_base": "https://TestSJF.openai.azure.com/openai/v1/",
"api_key": "REDACTED"
},
{
"model_name": "cerebras-llama-3.3-70b",
"model": "cerebras/llama-3.3-70b",
"api_base": "https://api.cerebras.ai/v1"
},
{
"model_name": "vivgrid-auto",
"model": "vivgrid/auto",
"api_base": "https://api.vivgrid.com/v1"
},
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_base": "https://ark.cn-beijing.volces.com/api/v3"
},
{
"model_name": "doubao-pro",
"model": "volcengine/doubao-pro-32k",
"api_base": "https://ark.cn-beijing.volces.com/api/v3"
},
{
"model_name": "deepseek-v3",
"model": "shengsuanyun/deepseek-v3",
"api_base": "https://api.shengsuanyun.com/v1"
},
{
"model_name": "gemini-flash",
"model": "antigravity/gemini-3-flash",
"auth_method": "oauth"
},
{
"model_name": "copilot-gpt-5.4",
"model": "github-copilot/gpt-5.4",
"api_base": "http://localhost:4321",
"auth_method": "oauth"
},
{
"model_name": "llama3",
"model": "ollama/llama3",
"api_base": "http://localhost:11434/v1"
},
{
"model_name": "mistral-small",
"model": "mistral/mistral-small-latest",
"api_base": "https://api.mistral.ai/v1"
},
{
"model_name": "deepseek-v3.2",
"model": "avian/deepseek/deepseek-v3.2",
"api_base": "https://api.avian.io/v1"
},
{
"model_name": "kimi-k2.5",
"model": "avian/moonshotai/kimi-k2.5",
"api_base": "https://api.avian.io/v1"
},
{
"model_name": "MiniMax-M2.5",
"model": "minimax/MiniMax-M2.5",
"api_base": "https://api.minimaxi.com/v1",
"extra_body": {
"reasoning_split": true
}
},
{
"model_name": "LongCat-Flash-Thinking",
"model": "longcat/LongCat-Flash-Thinking",
"api_base": "https://api.longcat.chat/openai"
},
{
"model_name": "modelscope-qwen",
"model": "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
"api_base": "https://api-inference.modelscope.cn/v1"
},
{
"model_name": "local-model",
"model": "vllm/custom-model",
"api_base": "http://localhost:8000/v1"
},
{
"model_name": "azure-gpt5",
"model": "azure/my-gpt5-deployment",
"api_base": "https://your-resource.openai.azure.com"
}
],
"gateway": {
"host": "0.0.0.0",
"port": 18790,
"chat_enabled": true,
"hot_reload": true,
"log_level": "info",
"api_key": "picoclaw-secret-123"
},
"hooks": {
"enabled": true,
"defaults": {
"observer_timeout_ms": 500,
"interceptor_timeout_ms": 5000,
"approval_timeout_ms": 60000
}
},
"tools": {
"filter_sensitive_data": true,
"filter_min_length": 8,
"allow_read_paths": null,
"allow_write_paths": null,
"deny_read_paths": [
"^skills(/.*)?$"
],
"deny_write_paths": [
"^skills(/.*)?$"
],
"web": {
"enabled": true,
"brave": {
"enabled": false,
"max_results": 5
},
"tavily": {
"enabled": false,
"base_url": "",
"max_results": 5
},
"duckduckgo": {
"enabled": true,
"max_results": 5
},
"perplexity": {
"enabled": false,
"max_results": 5
},
"searxng": {
"enabled": false,
"base_url": "",
"max_results": 5
},
"glm_search": {
"enabled": false,
"base_url": "https://open.bigmodel.cn/api/paas/v4/web_search",
"search_engine": "search_std",
"max_results": 5
},
"baidu_search": {
"enabled": false,
"base_url": "https://qianfan.baidubce.com/v2/ai_search/web_search",
"max_results": 10
},
"prefer_native": true,
"fetch_limit_bytes": 10485760,
"format": "plaintext"
},
"cron": {
"enabled": true,
"exec_timeout_minutes": 5,
"allow_command": true
},
"exec": {
"enabled": true,
"enable_deny_patterns": true,
"allow_remote": true,
"custom_deny_patterns": null,
"custom_allow_patterns": null,
"timeout_seconds": 60
},
"skills": {
"whitelist_enabled": true,
"whitelist": [
"weather",
"summarize"
],
"enabled": true,
"registries": {
"clawhub": {
"enabled": true,
"base_url": "https://clawhub.ai",
"search_path": "",
"skills_path": "",
"download_path": "",
"timeout": 0,
"max_zip_size": 0,
"max_response_size": 0
}
},
"github": {},
"max_concurrent_searches": 2,
"search_cache": {
"max_size": 50,
"ttl_seconds": 300
}
},
"media_cleanup": {
"enabled": true,
"max_age_minutes": 30,
"interval_minutes": 5
},
"mcp": {
"enabled": true,
"discovery": {
"enabled": false,
"ttl": 5,
"max_search_results": 5,
"use_bm25": true,
"use_regex": false
},
"servers": {}
},
"whitelist": [
"spawn",
"subagent",
"read_file",
"list_dir",
"write_file",
"edit_file",
"append_file",
"message",
"weather",
"summarize",
"github",
"search_tool"
],
"whitelist_enabled": true,
"append_file": {
"enabled": true
},
"edit_file": {
"enabled": true
},
"find_skills": {
"enabled": true
},
"i2c": {
"enabled": false
},
"install_skill": {
"enabled": true
},
"list_dir": {
"enabled": true
},
"message": {
"enabled": true
},
"read_file": {
"enabled": true,
"max_read_file_size": 65536
},
"send_file": {
"enabled": true
},
"spawn": {
"enabled": true
},
"spawn_status": {
"enabled": false
},
"spi": {
"enabled": false
},
"subagent": {
"enabled": true
},
"web_fetch": {
"enabled": true
},
"write_file": {
"enabled": true
}
},
"heartbeat": {
"enabled": true,
"interval": 30
},
"devices": {
"enabled": false,
"monitor_usb": true
},
"voice": {
"echo_transcription": false
},
"build_info": {
"version": "0.1.0",
"git_commit": "054b55fd",
"build_time": "2026-03-23T10:15:13+0100",
"go_version": "go1.26.1"
}
}

View file

@ -12,7 +12,11 @@ The `/chat` endpoint allows you to interact with the PicoClaw agent via a simple
Start a new chat request.
<<<<<<< HEAD
**Endpoint:** `POST /chat`
=======
**Endpoint:** `POST /chat`
>>>>>>> security_shield_v2
**Content-Type:** `application/json`
**Request Body:**

View file

@ -556,6 +556,9 @@ For example:
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
- `PICOCLAW_TOOLS_MCP_MAX_INLINE_TEXT_CHARS=16384`
Note: Nested map-style config (for example `tools.mcp.servers.<name>.*`) is configured in `config.json` rather than
environment variables.
For MCP tools, `tools.mcp.max_inline_text_chars` controls how much text result is kept inline in model context. The threshold is counted in Unicode characters (Go runes), not bytes. For example, `16384` means up to 16,384 characters inline, which may occupy more than 16 KB for multibyte text such as CJK. Above this threshold, PicoClaw saves the MCP text result as a local artifact in the agent workspace and gives the model a short note plus a structured `[file:...]` artifact path instead of injecting the full payload into context.

View file

@ -59,11 +59,7 @@ data:
},
"placeholder": {
"enabled": true,
<<<<<<< HEAD
"text": "Thinking... 💭"
=======
"text": "Thinking... \ud83d\udcad"
>>>>>>> fix/isolation-hardening
},
"streaming": {
"enabled": true,
@ -136,11 +132,7 @@ data:
},
"placeholder": {
"enabled": true,
<<<<<<< HEAD
"text": "Thinking... 💭"
=======
"text": "Thinking... \ud83d\udcad"
>>>>>>> fix/isolation-hardening
},
"reasoning_channel_id": ""
},

2
logs/gateway.log Normal file
View file

@ -0,0 +1,2 @@
{"level":"warn","path":"/home/stevef/dev/tomerge/github/picoclaw/config.json","time":"2026-03-24T08:13:49+01:00","caller":"/home/stevef/dev/tomerge/github/picoclaw/pkg/config/config.go:1363","message":"config file not found, using default config"}
{"level":"warn","path":"/home/stevef/dev/tomerge/github/picoclaw/config.json","time":"2026-03-24T08:15:23+01:00","caller":"/home/stevef/dev/tomerge/github/picoclaw/pkg/config/config.go:1363","message":"config file not found, using default config"}

26
logs/gateway_panic.log Normal file
View file

@ -0,0 +1,26 @@
Error: error creating provider: model "" not found in model_list: model "" not found in model_list or providers
Usage:
picoclaw gateway [flags]
Aliases:
gateway, g
Flags:
-E, --allow-empty Continue starting even when no default model is configured
-d, --debug Enable debug logging
-h, --help help for gateway
-T, --no-truncate Disable string truncation in debug logs
Error: error creating provider: model "" not found in model_list: model "" not found in model_list or providers
Usage:
picoclaw gateway [flags]
Aliases:
gateway, g
Flags:
-E, --allow-empty Continue starting even when no default model is configured
-d, --debug Enable debug logging
-h, --help help for gateway
-T, --no-truncate Disable string truncation in debug logs

View file

@ -123,7 +123,6 @@ func NewAgentInstance(
if agentCfg != nil && strings.TrimSpace(agentCfg.SystemPrompt) != "" {
effectiveSystemPrompt = strings.TrimSpace(agentCfg.SystemPrompt)
}
contextBuilder := NewContextBuilder(workspace, baseWorkspace).
WithToolDiscovery(
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25,

View file

@ -148,6 +148,8 @@ func (al *AgentLoop) RegisterMCPToolsToAgent(agentID string, agent *AgentInstanc
for _, tool := range conn.Tools {
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
mcpTool.SetWorkspace(agent.Workspace)
mcpTool.SetMaxInlineTextRunes(al.cfg.Tools.MCP.GetMaxInlineTextChars())
if registerAsHidden {
agent.Tools.RegisterHidden(mcpTool)

1
pkg/agent/secret.txt Normal file
View file

@ -0,0 +1 @@
isolated-content

View file

@ -982,10 +982,21 @@ type MCPServerConfig struct {
type MCPConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"`
Discovery ToolDiscoveryConfig ` json:"discovery"`
// MaxInlineTextChars controls how much MCP text stays inline before it is saved as an artifact.
MaxInlineTextChars int `json:"max_inline_text_chars,omitempty" env:"PICOCLAW_TOOLS_MCP_MAX_INLINE_TEXT_CHARS"`
// Servers is a map of server name to server configuration
Servers map[string]MCPServerConfig `json:"servers,omitempty"`
}
const DefaultMCPMaxInlineTextChars = 16 * 1024
func (c *MCPConfig) GetMaxInlineTextChars() int {
if c.MaxInlineTextChars > 0 {
return c.MaxInlineTextChars
}
return DefaultMCPMaxInlineTextChars
}
func LoadConfig(path string) (*Config, error) {
logger.Debugf("loading config from %s", path)

View file

@ -198,6 +198,41 @@ func TestAgentConfig_FullParse(t *testing.T) {
}
}
func TestDefaultConfig_MCPMaxInlineTextChars(t *testing.T) {
cfg := DefaultConfig()
if cfg.Tools.MCP.GetMaxInlineTextChars() != DefaultMCPMaxInlineTextChars {
t.Fatalf(
"DefaultConfig().Tools.MCP.GetMaxInlineTextChars() = %d, want %d",
cfg.Tools.MCP.GetMaxInlineTextChars(),
DefaultMCPMaxInlineTextChars,
)
}
}
func TestLoadConfig_MCPMaxInlineTextChars(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
raw := `{
"tools": {
"mcp": {
"enabled": true,
"max_inline_text_chars": 2048
}
}
}`
if err := os.WriteFile(configPath, []byte(raw), 0o644); err != nil {
t.Fatalf("WriteFile(configPath): %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
if got := cfg.Tools.MCP.GetMaxInlineTextChars(); got != 2048 {
t.Fatalf("cfg.Tools.MCP.GetMaxInlineTextChars() = %d, want 2048", got)
}
}
func TestConfig_BackwardCompat_NoAgentsList(t *testing.T) {
jsonData := `{
"agents": {

View file

@ -465,6 +465,7 @@ func DefaultConfig() *Config {
UseBM25: true,
UseRegex: false,
},
MaxInlineTextChars: DefaultMCPMaxInlineTextChars,
Servers: map[string]MCPServerConfig{},
},
AppendFile: ToolConfig{

View file

@ -309,8 +309,8 @@ type HandlerMux interface {
HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request))
}
// RegisterOnMux registers /health, /ready, /reload and /chat handlers onto the given mux.
// This allows the health endpoints to be served by a shared HTTP server.
// RegisterOnMux registers /health, /ready, /reload and /chat handlers onto the
// given mux. This allows the health endpoints to be served by a shared HTTP server.
func (s *Server) RegisterOnMux(mux HandlerMux) {
mux.HandleFunc("/health", s.healthHandler)
mux.HandleFunc("/ready", s.readyHandler)

View file

@ -283,12 +283,15 @@ func NewReadFileTool(
workspace string,
restrict bool,
maxReadFileSize int,
allowPaths []*regexp.Regexp,
denyPaths ...[]*regexp.Regexp,
configs ...[]*regexp.Regexp,
) *ReadFileTool {
var allowPatterns []*regexp.Regexp
var denyPatterns []*regexp.Regexp
if len(denyPaths) > 0 {
denyPatterns = denyPaths[0]
if len(configs) > 0 {
allowPatterns = configs[0]
}
if len(configs) > 1 {
denyPatterns = configs[1]
}
maxSize := int64(maxReadFileSize)
@ -297,7 +300,7 @@ func NewReadFileTool(
}
return &ReadFileTool{
fs: buildFs(workspace, restrict, allowPaths, denyPatterns),
fs: buildFs(workspace, restrict, allowPatterns, denyPatterns),
maxSize: maxSize,
}
}
@ -306,22 +309,24 @@ func NewReadFileBytesTool(
workspace string,
restrict bool,
maxReadFileSize int,
allowPaths []*regexp.Regexp,
denyPaths ...[]*regexp.Regexp,
configs ...[]*regexp.Regexp,
) *ReadFileTool {
return NewReadFileTool(workspace, restrict, maxReadFileSize, allowPaths, denyPaths...)
return NewReadFileTool(workspace, restrict, maxReadFileSize, configs...)
}
func NewReadFileLinesTool(
workspace string,
restrict bool,
maxReadFileSize int,
allowPaths []*regexp.Regexp,
denyPaths ...[]*regexp.Regexp,
configs ...[]*regexp.Regexp,
) *ReadFileLinesTool {
var allowPatterns []*regexp.Regexp
var denyPatterns []*regexp.Regexp
if len(denyPaths) > 0 {
denyPatterns = denyPaths[0]
if len(configs) > 0 {
allowPatterns = configs[0]
}
if len(configs) > 1 {
denyPatterns = configs[1]
}
maxSize := int64(maxReadFileSize)
@ -330,7 +335,7 @@ func NewReadFileLinesTool(
}
return &ReadFileLinesTool{
fs: buildFs(workspace, restrict, allowPaths, denyPatterns),
fs: buildFs(workspace, restrict, allowPatterns, denyPatterns),
maxSize: maxSize,
}
}
@ -869,12 +874,16 @@ type WriteFileTool struct {
fs fileSystem
}
func NewWriteFileTool(workspace string, restrict bool, allowPaths []*regexp.Regexp, denyPaths ...[]*regexp.Regexp) *WriteFileTool {
func NewWriteFileTool(workspace string, restrict bool, configs ...[]*regexp.Regexp) *WriteFileTool {
var allowPatterns []*regexp.Regexp
var denyPatterns []*regexp.Regexp
if len(denyPaths) > 0 {
denyPatterns = denyPaths[0]
if len(configs) > 0 {
allowPatterns = configs[0]
}
return &WriteFileTool{fs: buildFs(workspace, restrict, allowPaths, denyPatterns)}
if len(configs) > 1 {
denyPatterns = configs[1]
}
return &WriteFileTool{fs: buildFs(workspace, restrict, allowPatterns, denyPatterns)}
}
func (t *WriteFileTool) Name() string {
@ -939,12 +948,16 @@ type ListDirTool struct {
fs fileSystem
}
func NewListDirTool(workspace string, restrict bool, allowPaths []*regexp.Regexp, denyPaths ...[]*regexp.Regexp) *ListDirTool {
func NewListDirTool(workspace string, restrict bool, configs ...[]*regexp.Regexp) *ListDirTool {
var allowPatterns []*regexp.Regexp
var denyPatterns []*regexp.Regexp
if len(denyPaths) > 0 {
denyPatterns = denyPaths[0]
if len(configs) > 0 {
allowPatterns = configs[0]
}
return &ListDirTool{fs: buildFs(workspace, restrict, allowPaths, denyPatterns)}
if len(configs) > 1 {
denyPatterns = configs[1]
}
return &ListDirTool{fs: buildFs(workspace, restrict, allowPatterns, denyPatterns)}
}
func (t *ListDirTool) Name() string {

View file

@ -6,11 +6,14 @@ import (
"fmt"
"hash/fnv"
"os"
"path/filepath"
"strings"
"time"
"unicode/utf8"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
)
@ -30,6 +33,8 @@ type MCPTool struct {
serverName string
tool *mcp.Tool
mediaStore media.MediaStore
workspace string
maxInlineTextRunes int
}
// NewMCPTool creates a new MCP tool wrapper
@ -38,6 +43,7 @@ func NewMCPTool(manager MCPManager, serverName string, tool *mcp.Tool) *MCPTool
manager: manager,
serverName: serverName,
tool: tool,
maxInlineTextRunes: maxMCPInlineTextRunes,
}
}
@ -45,6 +51,18 @@ func (t *MCPTool) SetMediaStore(store media.MediaStore) {
t.mediaStore = store
}
func (t *MCPTool) SetWorkspace(workspace string) {
t.workspace = strings.TrimSpace(workspace)
}
func (t *MCPTool) SetMaxInlineTextRunes(limit int) {
if limit > 0 {
t.maxInlineTextRunes = limit
}
}
const maxMCPInlineTextRunes = 16 * 1024
// sanitizeIdentifierComponent normalizes a string so it can be safely used
// as part of a tool/function identifier for downstream providers.
// It:
@ -255,14 +273,19 @@ func extractContentText(content []mcp.Content) string {
func (t *MCPTool) normalizeResultContent(ctx context.Context, content []mcp.Content) *ToolResult {
llmParts := make([]string, 0, len(content))
rawTextParts := make([]string, 0, len(content))
mediaRefs := make([]string, 0, len(content))
for _, c := range content {
switch v := c.(type) {
case *mcp.TextContent:
text := strings.TrimSpace(sanitizeToolLLMContent(v.Text))
if text != "" {
llmParts = append(llmParts, text)
rawText := strings.TrimSpace(v.Text)
if rawText != "" {
rawTextParts = append(rawTextParts, rawText)
}
safeText := strings.TrimSpace(sanitizeToolLLMContent(v.Text))
if safeText != "" {
llmParts = append(llmParts, safeText)
}
case *mcp.ImageContent:
ref, note := t.storeBinaryContent(
@ -295,10 +318,13 @@ func (t *MCPTool) normalizeResultContent(ctx context.Context, content []mcp.Cont
case *mcp.ResourceLink:
llmParts = append(llmParts, summarizeResourceLink(v))
case *mcp.EmbeddedResource:
ref, note := t.storeEmbeddedResource(ctx, v)
ref, note, rawText := t.storeEmbeddedResource(ctx, v)
if ref != "" {
mediaRefs = append(mediaRefs, ref)
}
if rawText != "" {
rawTextParts = append(rawTextParts, rawText)
}
if note != "" {
llmParts = append(llmParts, note)
}
@ -307,34 +333,105 @@ func (t *MCPTool) normalizeResultContent(ctx context.Context, content []mcp.Cont
}
}
forLLM := strings.Join(compactStrings(llmParts), "\n")
rawText := strings.Join(compactStrings(rawTextParts), "\n")
if artifactResult := t.persistLargeTextArtifact(rawText); artifactResult != nil {
artifactResult.Media = mediaRefs
return artifactResult
}
result := &ToolResult{
ForLLM: strings.Join(compactStrings(llmParts), "\n"),
ForLLM: forLLM,
Media: mediaRefs,
}
return result
}
func (t *MCPTool) storeEmbeddedResource(ctx context.Context, content *mcp.EmbeddedResource) (string, string) {
func (t *MCPTool) persistLargeTextArtifact(text string) *ToolResult {
text = strings.TrimSpace(text)
limit := t.maxInlineTextRunes
if limit <= 0 {
limit = maxMCPInlineTextRunes
}
size := utf8.RuneCountInString(text)
if text == "" || size <= limit || t.workspace == "" {
return nil
}
dir := filepath.Join(t.workspace, ".artifacts", "mcp")
if err := os.MkdirAll(dir, 0o700); err != nil {
return t.largeTextArtifactFallback(text, err)
}
// TODO: Add lifecycle cleanup/retention for MCP artifact files.
pattern := fmt.Sprintf(
"%s_%s_*.txt",
sanitizeIdentifierComponent(t.serverName),
sanitizeIdentifierComponent(t.tool.Name),
)
tmpFile, err := os.CreateTemp(dir, pattern)
if err != nil {
return t.largeTextArtifactFallback(text, err)
}
path := tmpFile.Name()
if _, err = tmpFile.WriteString(text); err != nil {
_ = tmpFile.Close()
_ = os.Remove(path)
return t.largeTextArtifactFallback(text, err)
}
if err = tmpFile.Close(); err != nil {
_ = os.Remove(path)
return t.largeTextArtifactFallback(text, err)
}
return &ToolResult{
ForLLM: fmt.Sprintf(
"[MCP returned a large text result (%d chars); omitted from model context and saved as a local artifact.]",
size,
),
ArtifactTags: []string{"[file:" + path + "]"},
}
}
func (t *MCPTool) largeTextArtifactFallback(text string, err error) *ToolResult {
size := utf8.RuneCountInString(text)
logger.WarnCF("tool", "Failed to persist large MCP text artifact", map[string]any{
"server": t.serverName,
"tool": t.tool.Name,
"chars": size,
"error": err.Error(),
})
return &ToolResult{
ForLLM: fmt.Sprintf(
"[MCP returned a large text result (%d chars); omitted from model context because artifact persistence failed.]",
size,
),
}
}
func (t *MCPTool) storeEmbeddedResource(ctx context.Context, content *mcp.EmbeddedResource) (string, string, string) {
if content == nil || content.Resource == nil {
return "", "[MCP returned an embedded resource without data.]"
return "", "[MCP returned an embedded resource without data.]", ""
}
resource := content.Resource
if len(resource.Blob) > 0 {
return t.storeBinaryContent(
ref, note := t.storeBinaryContent(
ctx,
"resource",
normalizedMIMEType(resource.MIMEType),
resource.Blob,
content.Annotations,
)
return ref, note, ""
}
if strings.TrimSpace(resource.Text) != "" {
return "", sanitizeToolLLMContent(resource.Text)
rawText := strings.TrimSpace(resource.Text)
if rawText != "" {
return "", sanitizeToolLLMContent(resource.Text), rawText
}
return "", summarizeEmbeddedResource(content)
return "", summarizeEmbeddedResource(content), ""
}
func (t *MCPTool) storeBinaryContent(

View file

@ -634,3 +634,177 @@ func TestMCPTool_Execute_LargeBase64TextIsOmittedFromContext(t *testing.T) {
t.Fatalf("expected sanitized large base64 note, got %q", result.ForLLM)
}
}
func TestMCPTool_Execute_LargeBase64TextArtifactPreservesRawPayload(t *testing.T) {
workspace := t.TempDir()
largeBase64 := strings.Repeat("QUJD", 400)
manager := &MockMCPManager{
callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: largeBase64},
},
}, nil
},
}
mcpTool := NewMCPTool(manager, "test_server", &mcp.Tool{Name: "dump_payload"})
mcpTool.SetWorkspace(workspace)
mcpTool.SetMaxInlineTextRunes(32)
result := mcpTool.Execute(context.Background(), nil)
if !strings.Contains(result.ForLLM, "saved as a local artifact") {
t.Fatalf("expected artifact note, got %q", result.ForLLM)
}
if result.ForLLM == largeBase64OmittedMessage {
t.Fatalf("expected artifact note instead of sanitized base64 placeholder")
}
if len(result.ArtifactTags) != 1 {
t.Fatalf("expected 1 artifact tag, got %d", len(result.ArtifactTags))
}
tag := result.ArtifactTags[0]
const prefix = "[file:"
if !strings.HasPrefix(tag, prefix) || !strings.HasSuffix(tag, "]") {
t.Fatalf("expected file artifact tag, got %q", tag)
}
path := strings.TrimSuffix(strings.TrimPrefix(tag, prefix), "]")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("expected artifact file to be readable: %v", err)
}
if string(data) != largeBase64 {
t.Fatalf("expected artifact file contents to preserve raw MCP payload")
}
}
func TestMCPTool_Execute_LargeTextStoredAsArtifact(t *testing.T) {
workspace := t.TempDir()
largeText := strings.Repeat("This is a large MCP text payload.\n", 800)
manager := &MockMCPManager{
callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: largeText},
},
}, nil
},
}
mcpTool := NewMCPTool(manager, "test_server", &mcp.Tool{Name: "dump_payload"})
mcpTool.SetWorkspace(workspace)
result := mcpTool.Execute(context.Background(), nil)
if strings.Contains(result.ForLLM, "This is a large MCP text payload") {
t.Fatalf("expected large MCP text to be omitted from ForLLM, got %q", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "saved as a local artifact") {
t.Fatalf("expected artifact note, got %q", result.ForLLM)
}
if len(result.ArtifactTags) != 1 {
t.Fatalf("expected 1 artifact tag, got %d", len(result.ArtifactTags))
}
tag := result.ArtifactTags[0]
const prefix = "[file:"
if !strings.HasPrefix(tag, prefix) || !strings.HasSuffix(tag, "]") {
t.Fatalf("expected file artifact tag, got %q", tag)
}
path := strings.TrimSuffix(strings.TrimPrefix(tag, prefix), "]")
if !strings.HasPrefix(path, workspace) {
t.Fatalf("expected artifact inside workspace, got %q", path)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("expected artifact file to be readable: %v", err)
}
if string(data) != strings.TrimSpace(largeText) {
t.Fatalf("expected artifact file contents to match source text")
}
}
func TestMCPTool_Execute_CustomInlineTextThreshold(t *testing.T) {
workspace := t.TempDir()
text := strings.Repeat("small custom threshold text\n", 20)
manager := &MockMCPManager{
callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: text},
},
}, nil
},
}
mcpTool := NewMCPTool(manager, "test_server", &mcp.Tool{Name: "dump_payload"})
mcpTool.SetWorkspace(workspace)
mcpTool.SetMaxInlineTextRunes(32)
result := mcpTool.Execute(context.Background(), nil)
if len(result.ArtifactTags) != 1 {
t.Fatalf("expected custom threshold to persist artifact, got %+v", result)
}
if strings.Contains(result.ForLLM, "small custom threshold text") {
t.Fatalf("expected text to be omitted from ForLLM, got %q", result.ForLLM)
}
}
func TestMCPTool_Execute_LargeTextArtifactFailureStillOmitsContext(t *testing.T) {
workspaceRoot := t.TempDir()
workspaceFile := filepath.Join(workspaceRoot, "not-a-directory")
if err := os.WriteFile(workspaceFile, []byte("x"), 0o600); err != nil {
t.Fatalf("failed to create workspace file: %v", err)
}
largeText := strings.Repeat("This is a large MCP text payload.\n", 800)
manager := &MockMCPManager{
callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: largeText},
},
}, nil
},
}
mcpTool := NewMCPTool(manager, "test_server", &mcp.Tool{Name: "dump_payload"})
mcpTool.SetWorkspace(workspaceFile)
result := mcpTool.Execute(context.Background(), nil)
if strings.Contains(result.ForLLM, "This is a large MCP text payload") {
t.Fatalf("expected large MCP text to be omitted from ForLLM, got %q", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "artifact persistence failed") {
t.Fatalf("expected persistence failure note, got %q", result.ForLLM)
}
if len(result.ArtifactTags) != 0 {
t.Fatalf("expected no artifact tags on persistence failure, got %+v", result.ArtifactTags)
}
}
func TestMCPTool_Execute_WhitespaceWorkspaceDisablesArtifactPersistence(t *testing.T) {
largeText := strings.Repeat("This is a large MCP text payload.\n", 800)
manager := &MockMCPManager{
callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: largeText},
},
}, nil
},
}
mcpTool := NewMCPTool(manager, "test_server", &mcp.Tool{Name: "dump_payload"})
mcpTool.SetWorkspace(" \n\t ")
result := mcpTool.Execute(context.Background(), nil)
if len(result.ArtifactTags) != 0 {
t.Fatalf("expected no artifact tags for whitespace workspace, got %+v", result.ArtifactTags)
}
if !strings.Contains(result.ForLLM, "This is a large MCP text payload") {
t.Fatalf("expected large text to remain inline when workspace is blank, got %q", result.ForLLM)
}
}

View file

@ -114,6 +114,7 @@ test:
echo "pnpm not found, skipping frontend linting"; \
fi
# Lint and format
lint:
cd $(BACKEND_DIR) && ${WEB_GO} vet ./...

22
workspace/HEARTBEAT.md Normal file
View file

@ -0,0 +1,22 @@
# Heartbeat Check List
This file contains tasks for the heartbeat service to check periodically.
## Examples
- Check for unread messages
- Review upcoming calendar events
- Check device status (e.g., MaixCam)
## Instructions
- Execute ALL tasks listed below. Do NOT skip any task.
- For simple tasks (e.g., report current time), respond directly.
- For complex tasks that may take time, use the spawn tool to create a subagent.
- The spawn tool is async - subagent results will be sent to the user automatically.
- After spawning a subagent, CONTINUE to process remaining tasks.
- Only respond with HEARTBEAT_OK when ALL tasks are done AND nothing needs attention.
---
Add your heartbeat tasks below this line:

4
workspace/cron/jobs.json Normal file
View file

@ -0,0 +1,4 @@
{
"version": 1,
"jobs": []
}