diff --git a/.dockerignore b/.dockerignore index d632da5ea..f169f9361 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,7 +2,7 @@ .gitignore build/ .picoclaw/ -config/ +# config/ .env .env.example *.md diff --git a/TEAMS_ID_MAPPING_ANALYSIS.md b/TEAMS_ID_MAPPING_ANALYSIS.md new file mode 100644 index 000000000..f26b14fed --- /dev/null +++ b/TEAMS_ID_MAPPING_ANALYSIS.md @@ -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 | diff --git a/TEAMS_QUICK_REFERENCE.md b/TEAMS_QUICK_REFERENCE.md new file mode 100644 index 000000000..a789e1c34 --- /dev/null +++ b/TEAMS_QUICK_REFERENCE.md @@ -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 diff --git a/config/config.json.azure b/config/config.json.azure new file mode 100644 index 000000000..9a7ff3397 --- /dev/null +++ b/config/config.json.azure @@ -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" + } +} \ No newline at end of file diff --git a/docs/api.md b/docs/api.md index b2219f830..2c119a1f5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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:** diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md index 045127c9c..6947ac8af 100644 --- a/docs/tools_configuration.md +++ b/docs/tools_configuration.md @@ -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..*`) 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. diff --git a/k3s/configmap.yaml b/k3s/configmap.yaml index 7c7ffa013..2d6566ff3 100644 --- a/k3s/configmap.yaml +++ b/k3s/configmap.yaml @@ -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": "" }, diff --git a/logs/gateway.log b/logs/gateway.log new file mode 100644 index 000000000..770d23f8b --- /dev/null +++ b/logs/gateway.log @@ -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"} diff --git a/logs/gateway_panic.log b/logs/gateway_panic.log new file mode 100644 index 000000000..67e98bfaf --- /dev/null +++ b/logs/gateway_panic.log @@ -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 + diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index c042a03da..3da0538b8 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -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, diff --git a/pkg/agent/loop_mcp.go b/pkg/agent/loop_mcp.go index 8f25c74cd..5e7541d33 100644 --- a/pkg/agent/loop_mcp.go +++ b/pkg/agent/loop_mcp.go @@ -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) diff --git a/pkg/agent/secret.txt b/pkg/agent/secret.txt new file mode 100644 index 000000000..d1af05448 --- /dev/null +++ b/pkg/agent/secret.txt @@ -0,0 +1 @@ +isolated-content \ No newline at end of file diff --git a/pkg/config/config.go b/pkg/config/config.go index 5ce4973fb..15f52d62a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index a1410f940..8e58a684e 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -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": { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 3351a306a..fe48778f9 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -465,7 +465,8 @@ func DefaultConfig() *Config { UseBM25: true, UseRegex: false, }, - Servers: map[string]MCPServerConfig{}, + MaxInlineTextChars: DefaultMCPMaxInlineTextChars, + Servers: map[string]MCPServerConfig{}, }, AppendFile: ToolConfig{ Enabled: true, diff --git a/pkg/health/server.go b/pkg/health/server.go index 94b6cbcdd..273dc3ba9 100644 --- a/pkg/health/server.go +++ b/pkg/health/server.go @@ -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) diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index d04858bef..84e5a6388 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -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 { diff --git a/pkg/tools/mcp_tool.go b/pkg/tools/mcp_tool.go index 5bffb4e89..1caf390cf 100644 --- a/pkg/tools/mcp_tool.go +++ b/pkg/tools/mcp_tool.go @@ -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" ) @@ -26,18 +29,21 @@ type MCPManager interface { // MCPTool wraps an MCP tool to implement the Tool interface type MCPTool struct { - manager MCPManager - serverName string - tool *mcp.Tool - mediaStore media.MediaStore + manager MCPManager + serverName string + tool *mcp.Tool + mediaStore media.MediaStore + workspace string + maxInlineTextRunes int } // NewMCPTool creates a new MCP tool wrapper func NewMCPTool(manager MCPManager, serverName string, tool *mcp.Tool) *MCPTool { return &MCPTool{ - manager: manager, - serverName: serverName, - tool: tool, + 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( diff --git a/pkg/tools/mcp_tool_test.go b/pkg/tools/mcp_tool_test.go index 8bbac3bc7..f2b02d6f6 100644 --- a/pkg/tools/mcp_tool_test.go +++ b/pkg/tools/mcp_tool_test.go @@ -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) + } +} diff --git a/web/Makefile b/web/Makefile index d22113e05..2db6fb05f 100644 --- a/web/Makefile +++ b/web/Makefile @@ -114,6 +114,7 @@ test: echo "pnpm not found, skipping frontend linting"; \ fi + # Lint and format lint: cd $(BACKEND_DIR) && ${WEB_GO} vet ./... diff --git a/workspace/HEARTBEAT.md b/workspace/HEARTBEAT.md new file mode 100644 index 000000000..9a4e3ca80 --- /dev/null +++ b/workspace/HEARTBEAT.md @@ -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: diff --git a/workspace/cron/jobs.json b/workspace/cron/jobs.json new file mode 100644 index 000000000..b8cdc503b --- /dev/null +++ b/workspace/cron/jobs.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "jobs": [] +} \ No newline at end of file