chore: stabilize build pipeline, fix lints and test panics
This commit is contained in:
parent
be393a6439
commit
066a6bc727
30 changed files with 235 additions and 801 deletions
|
|
@ -2,7 +2,7 @@
|
||||||
.gitignore
|
.gitignore
|
||||||
build/
|
build/
|
||||||
.picoclaw/
|
.picoclaw/
|
||||||
# config/
|
config/
|
||||||
.env
|
.env
|
||||||
.env.example
|
.env.example
|
||||||
*.md
|
*.md
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -16,8 +16,10 @@ cmd/**/workspace
|
||||||
|
|
||||||
# PicoClaw
|
# PicoClaw
|
||||||
.picoclaw/
|
.picoclaw/
|
||||||
|
pkg/agent/secret.txt
|
||||||
config.json
|
config.json
|
||||||
sessions/
|
sessions/
|
||||||
|
logs/
|
||||||
build/
|
build/
|
||||||
|
|
||||||
# Coverage
|
# Coverage
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
version: "2"
|
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
default: all
|
default: all
|
||||||
|
|
@ -10,25 +9,21 @@ linters:
|
||||||
- dupword
|
- dupword
|
||||||
- err113
|
- err113
|
||||||
- exhaustruct
|
- exhaustruct
|
||||||
- funcorder
|
|
||||||
- gochecknoglobals
|
- gochecknoglobals
|
||||||
- godot
|
- godot
|
||||||
- intrange
|
- intrange
|
||||||
- ireturn
|
- ireturn
|
||||||
- nlreturn
|
- nlreturn
|
||||||
- noctx
|
- noctx
|
||||||
- noinlineerr
|
|
||||||
- nonamedreturns
|
- nonamedreturns
|
||||||
- tagliatelle
|
- tagliatelle
|
||||||
- testpackage
|
- testpackage
|
||||||
- varnamelen
|
- varnamelen
|
||||||
- wrapcheck
|
- wrapcheck
|
||||||
- wsl
|
- wsl
|
||||||
- wsl_v5
|
|
||||||
|
|
||||||
# TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step)
|
# TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step)
|
||||||
- contextcheck
|
- contextcheck
|
||||||
- embeddedstructfieldcheck
|
|
||||||
- errcheck
|
- errcheck
|
||||||
- errchkjson
|
- errchkjson
|
||||||
- errorlint
|
- errorlint
|
||||||
|
|
@ -47,7 +42,6 @@ linters:
|
||||||
- lll
|
- lll
|
||||||
- maintidx
|
- maintidx
|
||||||
- mnd
|
- mnd
|
||||||
- modernize
|
|
||||||
- nestif
|
- nestif
|
||||||
- nilnil
|
- nilnil
|
||||||
- paralleltest
|
- paralleltest
|
||||||
|
|
|
||||||
7
Makefile
7
Makefile
|
|
@ -56,7 +56,8 @@ PTY_PATCH_LOONG64=pty_dir=$$(go env GOMODCACHE)/github.com/creack/pty@v1.1.9; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Golangci-lint
|
# Golangci-lint
|
||||||
GOLANGCI_LINT?=golangci-lint
|
GOLANGCI_LINT_BIN := $(shell if [ -f $(CURDIR)/golangci-lint ]; then echo $(CURDIR)/golangci-lint; else echo golangci-lint; fi)
|
||||||
|
GOLANGCI_LINT?=$(GOLANGCI_LINT_BIN)
|
||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
INSTALL_PREFIX?=$(HOME)/.local
|
INSTALL_PREFIX?=$(HOME)/.local
|
||||||
|
|
@ -293,8 +294,8 @@ update-deps:
|
||||||
@$(GO) get -u ./...
|
@$(GO) get -u ./...
|
||||||
@$(GO) mod tidy
|
@$(GO) mod tidy
|
||||||
|
|
||||||
## check: Run vet, fmt, and verify dependencies
|
## check: Run vet, fmt, lint, and verify dependencies
|
||||||
check: deps fmt vet test
|
check: deps fmt vet lint test
|
||||||
|
|
||||||
## run: Build and run picoclaw
|
## run: Build and run picoclaw
|
||||||
run: build
|
run: build
|
||||||
|
|
|
||||||
|
|
@ -1,363 +0,0 @@
|
||||||
# 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 |
|
|
||||||
|
|
@ -1,315 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -145,11 +145,9 @@ func (a *App) showChannelEditForm(configPath, channelName string, existing map[s
|
||||||
}
|
}
|
||||||
|
|
||||||
updated := make(map[string]any)
|
updated := make(map[string]any)
|
||||||
if existing != nil {
|
|
||||||
for k, v := range existing {
|
for k, v := range existing {
|
||||||
updated[k] = v
|
updated[k] = v
|
||||||
}
|
}
|
||||||
}
|
|
||||||
for k, field := range fields {
|
for k, field := range fields {
|
||||||
val := field.GetText()
|
val := field.GetText()
|
||||||
if val == "true" {
|
if val == "true" {
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||||
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
for {
|
for {
|
||||||
fmt.Print(fmt.Sprintf("%s You: ", internal.Logo))
|
fmt.Printf("%s You: ", internal.Logo)
|
||||||
line, err := reader.ReadString('\n')
|
line, err := reader.ReadString('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,13 @@ By default, the gateway listens on `127.0.0.1:18790`.
|
||||||
|
|
||||||
## 💬 Chat API
|
## 💬 Chat API
|
||||||
|
|
||||||
The `/chat` (and alias `/cgat`) endpoint allows you to interact with the PicoClaw agent via a simple HTTP interface. This API is designed to be **asynchronous** to avoid timeouts during long-running LLM tasks or tool executions.
|
The `/chat` endpoint allows you to interact with the PicoClaw agent via a simple HTTP interface. This API is designed to be **asynchronous** to avoid timeouts during long-running LLM tasks or tool executions.
|
||||||
|
|
||||||
### 1. Initiate a Chat Session (POST)
|
### 1. Initiate a Chat Session (POST)
|
||||||
|
|
||||||
Start a new chat request.
|
Start a new chat request.
|
||||||
|
|
||||||
**Endpoint:** `POST /chat` (or `POST /cgat`)
|
**Endpoint:** `POST /chat`
|
||||||
**Content-Type:** `application/json`
|
**Content-Type:** `application/json`
|
||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
|
|
@ -35,7 +35,7 @@ Start a new chat request.
|
||||||
|
|
||||||
Retrieve the status and response of a previously initiated session.
|
Retrieve the status and response of a previously initiated session.
|
||||||
|
|
||||||
**Endpoint:** `GET /chat?session_id=<ID>` (or `GET /cgat?session_id=<ID>`)
|
**Endpoint:** `GET /chat?session_id=<ID>`
|
||||||
|
|
||||||
**Possible Responses:**
|
**Possible Responses:**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
{"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"}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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
|
|
||||||
|
|
||||||
|
|
@ -342,11 +342,7 @@ func (cb *ContextBuilder) sourceFilesChangedLocked() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if skillFilesChangedSince(cb.skillRoots(), cb.skillFilesAtCache) {
|
return skillFilesChangedSince(cb.skillRoots(), cb.skillFilesAtCache)
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileChangedSince returns true if a tracked source file has been modified,
|
// fileChangedSince returns true if a tracked source file has been modified,
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,6 @@ type AgentLoop struct {
|
||||||
// Agent instance caching for multi-user isolation
|
// Agent instance caching for multi-user isolation
|
||||||
// Each unique chatID gets its own agent instance to maintain state/model selection
|
// Each unique chatID gets its own agent instance to maintain state/model selection
|
||||||
agentCache sync.Map // key: channel:chatID, value: *AgentInstance
|
agentCache sync.Map // key: channel:chatID, value: *AgentInstance
|
||||||
agentCacheMu sync.RWMutex
|
|
||||||
agentCacheTTL time.Duration // How long to keep cached agents alive
|
agentCacheTTL time.Duration // How long to keep cached agents alive
|
||||||
agentCleaner *time.Ticker // Periodic cleanup of stale cached agents
|
agentCleaner *time.Ticker // Periodic cleanup of stale cached agents
|
||||||
lastCacheCheck sync.Map // key: channel:chatID, value: time.Time (last access time)
|
lastCacheCheck sync.Map // key: channel:chatID, value: time.Time (last access time)
|
||||||
|
|
@ -160,6 +159,19 @@ func NewAgentLoop(
|
||||||
cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),
|
cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),
|
||||||
steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)),
|
steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
al.agentCacheTTL = 24 * time.Hour
|
||||||
|
cleanInterval := 1 * time.Hour
|
||||||
|
if cfg.Agents.Defaults.AgentCacheTTLSeconds > 0 {
|
||||||
|
al.agentCacheTTL = time.Duration(cfg.Agents.Defaults.AgentCacheTTLSeconds) * time.Second
|
||||||
|
cleanInterval = al.agentCacheTTL / 10
|
||||||
|
if cleanInterval < 1*time.Minute {
|
||||||
|
cleanInterval = 1 * time.Minute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
al.agentCleaner = time.NewTicker(cleanInterval)
|
||||||
|
go al.agentCacheCleanupLoop()
|
||||||
|
|
||||||
al.hooks = NewHookManager(eventBus)
|
al.hooks = NewHookManager(eventBus)
|
||||||
configureHookManagerFromConfig(al.hooks, cfg)
|
configureHookManagerFromConfig(al.hooks, cfg)
|
||||||
al.contextManager = al.resolveContextManager()
|
al.contextManager = al.resolveContextManager()
|
||||||
|
|
@ -796,6 +808,28 @@ func (al *AgentLoop) UnmountHook(name string) {
|
||||||
al.hooks.Unmount(name)
|
al.hooks.Unmount(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) agentCacheCleanupLoop() {
|
||||||
|
if al.agentCleaner == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for range al.agentCleaner.C {
|
||||||
|
now := time.Now()
|
||||||
|
al.lastCacheCheck.Range(func(key, value any) bool {
|
||||||
|
lastAccess := value.(time.Time)
|
||||||
|
if now.Sub(lastAccess) > al.agentCacheTTL {
|
||||||
|
// Evict stale isolated agent
|
||||||
|
al.agentCache.Delete(key)
|
||||||
|
al.lastCacheCheck.Delete(key)
|
||||||
|
logger.InfoCF("agent", "Evicted stale isolated agent", map[string]any{
|
||||||
|
"cache_key": key,
|
||||||
|
"ttl": al.agentCacheTTL.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SubscribeEvents registers a subscriber for agent-loop events.
|
// SubscribeEvents registers a subscriber for agent-loop events.
|
||||||
func (al *AgentLoop) SubscribeEvents(buffer int) EventSubscription {
|
func (al *AgentLoop) SubscribeEvents(buffer int) EventSubscription {
|
||||||
if al == nil || al.eventBus == nil {
|
if al == nil || al.eventBus == nil {
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,6 @@ func (r *mcpRuntime) setManager(manager *mcp.Manager) {
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mcpRuntime) setInitErr(err error) {
|
|
||||||
r.mu.Lock()
|
|
||||||
r.initErr = err
|
|
||||||
r.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mcpRuntime) getInitErr() error {
|
func (r *mcpRuntime) getInitErr() error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
isolated-content
|
|
||||||
|
|
@ -1251,7 +1251,7 @@ func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, conten
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: direct send (should not happen)
|
// Fallback: direct send (should not happen)
|
||||||
channel, _ := m.channels[channelName]
|
channel := m.channels[channelName]
|
||||||
_, err := channel.Send(ctx, msg)
|
_, err := channel.Send(ctx, msg)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -824,7 +824,7 @@ func (c *OneBotChannel) parseMessageSegments(
|
||||||
|
|
||||||
case "face":
|
case "face":
|
||||||
if data != nil {
|
if data != nil {
|
||||||
faceID, _ := data["id"]
|
faceID := data["id"]
|
||||||
textParts = append(textParts, fmt.Sprintf("[face:%v]", faceID))
|
textParts = append(textParts, fmt.Sprintf("[face:%v]", faceID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -737,9 +737,7 @@ func (c *WeComChannel) uploadOutboundMedia(
|
||||||
finishEnv, err := c.sendCommandAck(wecomCommand{
|
finishEnv, err := c.sendCommandAck(wecomCommand{
|
||||||
Cmd: wecomCmdUploadMediaEnd,
|
Cmd: wecomCmdUploadMediaEnd,
|
||||||
Headers: wecomHeaders{ReqID: randomID(10)},
|
Headers: wecomHeaders{ReqID: randomID(10)},
|
||||||
Body: wecomUploadMediaFinishBody{
|
Body: wecomUploadMediaFinishBody(initResp),
|
||||||
UploadID: initResp.UploadID,
|
|
||||||
},
|
|
||||||
}, wecomUploadTimeout)
|
}, wecomUploadTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@ func (m AgentModelConfig) MarshalJSON() ([]byte, error) {
|
||||||
Primary string `json:"primary,omitempty"`
|
Primary string `json:"primary,omitempty"`
|
||||||
Fallbacks []string `json:"fallbacks,omitempty"`
|
Fallbacks []string `json:"fallbacks,omitempty"`
|
||||||
}
|
}
|
||||||
return json.Marshal(raw{Primary: m.Primary, Fallbacks: m.Fallbacks})
|
return json.Marshal(raw(m))
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentConfig struct {
|
type AgentConfig struct {
|
||||||
|
|
@ -249,6 +249,7 @@ type AgentDefaults struct {
|
||||||
SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker
|
SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker
|
||||||
ContextManager string `json:"context_manager,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER"`
|
ContextManager string `json:"context_manager,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER"`
|
||||||
ContextManagerConfig json.RawMessage `json:"context_manager_config,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER_CONFIG"`
|
ContextManagerConfig json.RawMessage `json:"context_manager_config,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER_CONFIG"`
|
||||||
|
AgentCacheTTLSeconds int `json:"agent_cache_ttl_seconds,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_AGENT_CACHE_TTL_SECONDS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
|
const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ func DefaultConfig() *Config {
|
||||||
MaxArgsLength: 300,
|
MaxArgsLength: 300,
|
||||||
},
|
},
|
||||||
SplitOnMarker: false,
|
SplitOnMarker: false,
|
||||||
|
AgentCacheTTLSeconds: 86400, // 24 hours
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Bindings: []AgentBinding{},
|
Bindings: []AgentBinding{},
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@ package health
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -52,6 +54,7 @@ type Server struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
chatResults map[string]*chatStatus
|
chatResults map[string]*chatStatus
|
||||||
chatResultsMu sync.RWMutex
|
chatResultsMu sync.RWMutex
|
||||||
|
rateLimits sync.Map // key: string (ID or IP), value: time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type Check struct {
|
type Check struct {
|
||||||
|
|
@ -82,7 +85,6 @@ func NewServer(host string, port int, token string) *Server {
|
||||||
mux.HandleFunc("/ready", s.readyHandler)
|
mux.HandleFunc("/ready", s.readyHandler)
|
||||||
mux.HandleFunc("/reload", s.reloadHandler)
|
mux.HandleFunc("/reload", s.reloadHandler)
|
||||||
mux.HandleFunc("/chat", s.chatHandler)
|
mux.HandleFunc("/chat", s.chatHandler)
|
||||||
mux.HandleFunc("/cgat", s.chatHandler)
|
|
||||||
|
|
||||||
// Start task cleanup goroutine
|
// Start task cleanup goroutine
|
||||||
go s.taskCleanupLoop()
|
go s.taskCleanupLoop()
|
||||||
|
|
@ -174,17 +176,47 @@ func (s *Server) SetAPIKey(key string) {
|
||||||
s.apiKey = key
|
s.apiKey = key
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) verifyAPIKey(r *http.Request) bool {
|
// SetAuthToken sets the expected Bearer token.
|
||||||
|
func (s *Server) SetAuthToken(token string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.authToken = token
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) verifyAuth(r *http.Request) bool {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
if s.apiKey == "" {
|
|
||||||
|
// If no authentication is configured, allow the request.
|
||||||
|
if s.apiKey == "" && s.authToken == "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return r.Header.Get("X-API-Key") == s.apiKey
|
|
||||||
|
// Check X-API-Key header.
|
||||||
|
if s.apiKey != "" {
|
||||||
|
gotKey := r.Header.Get("X-API-Key")
|
||||||
|
if subtle.ConstantTimeCompare([]byte(gotKey), []byte(s.apiKey)) == 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Authorization: Bearer <token> header.
|
||||||
|
if s.authToken != "" {
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
const prefix = "Bearer "
|
||||||
|
if len(authHeader) > len(prefix) && strings.EqualFold(authHeader[:len(prefix)], prefix) {
|
||||||
|
gotToken := authHeader[len(prefix):]
|
||||||
|
if subtle.ConstantTimeCompare([]byte(gotToken), []byte(s.authToken)) == 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.verifyAPIKey(r) {
|
if !s.verifyAuth(r) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||||
|
|
@ -284,11 +316,6 @@ func (s *Server) RegisterOnMux(mux HandlerMux) {
|
||||||
mux.HandleFunc("/ready", s.readyHandler)
|
mux.HandleFunc("/ready", s.readyHandler)
|
||||||
mux.HandleFunc("/reload", s.reloadHandler)
|
mux.HandleFunc("/reload", s.reloadHandler)
|
||||||
mux.HandleFunc("/chat", s.chatHandler)
|
mux.HandleFunc("/chat", s.chatHandler)
|
||||||
mux.HandleFunc("/cgat", s.chatHandler)
|
|
||||||
mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
logger.Error("GATEWAY IS HITTING ITSELF FOR LLM CALLS!")
|
|
||||||
http.Error(w, "GATEWAY LOOP DETECTION", http.StatusLoopDetected)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// chatHandler handles POST /chat (initiate async) and GET /chat (poll for result).
|
// chatHandler handles POST /chat (initiate async) and GET /chat (poll for result).
|
||||||
|
|
@ -297,13 +324,20 @@ func (s *Server) RegisterOnMux(mux HandlerMux) {
|
||||||
// GET query: ?session_id=...
|
// GET query: ?session_id=...
|
||||||
// GET response: {"response": "...", "status": "completed"}
|
// GET response: {"response": "...", "status": "completed"}
|
||||||
func (s *Server) chatHandler(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) chatHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.verifyAPIKey(r) {
|
if !s.verifyAuth(r) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
json.NewEncoder(w).Encode(ChatResponse{Error: "unauthorized"})
|
json.NewEncoder(w).Encode(ChatResponse{Error: "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !s.checkRateLimit(r) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
json.NewEncoder(w).Encode(ChatResponse{Error: "rate limit exceeded"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
s.handlePostChat(w, r)
|
s.handlePostChat(w, r)
|
||||||
return
|
return
|
||||||
|
|
@ -375,6 +409,8 @@ func (s *Server) handlePostChat(w http.ResponseWriter, r *http.Request) {
|
||||||
chatID = req.SessionID
|
chatID = req.SessionID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
chatID = s.sanitizeID(chatID)
|
||||||
|
sessionID = s.sanitizeID(sessionID)
|
||||||
|
|
||||||
if chatID != "" {
|
if chatID != "" {
|
||||||
logger.InfoCF("api", "Resolved isolation ID for request", map[string]any{
|
logger.InfoCF("api", "Resolved isolation ID for request", map[string]any{
|
||||||
|
|
@ -398,6 +434,9 @@ func (s *Server) handlePostChat(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
if sessionID == "" {
|
if sessionID == "" {
|
||||||
sessionID = fmt.Sprintf("chat-%d", time.Now().UnixNano())
|
sessionID = fmt.Sprintf("chat-%d", time.Now().UnixNano())
|
||||||
|
} else {
|
||||||
|
// Even if provided, sanitize the user-provided sessionID again to be sure
|
||||||
|
sessionID = s.sanitizeID(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize status
|
// Initialize status
|
||||||
|
|
@ -511,6 +550,45 @@ func (s *Server) taskCleanupLoop() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) sanitizeID(id string) string {
|
||||||
|
if len(id) > 128 {
|
||||||
|
id = id[:128]
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]rune, 0, len(id))
|
||||||
|
for _, r := range id {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||||
|
result = append(result, r)
|
||||||
|
} else {
|
||||||
|
result = append(result, '_')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) checkRateLimit(r *http.Request) bool {
|
||||||
|
// Simple rate limit: 1 request per second per ID or IP
|
||||||
|
// This is defensive against automated spamming.
|
||||||
|
key := r.Header.Get("X-PicoClaw-Chat-ID")
|
||||||
|
if key == "" {
|
||||||
|
key = r.RemoteAddr
|
||||||
|
// Strip port if present
|
||||||
|
if i := strings.LastIndex(key, ":"); i != -1 {
|
||||||
|
key = key[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if val, ok := s.rateLimits.Load(key); ok {
|
||||||
|
lastAccess := val.(time.Time)
|
||||||
|
if time.Since(lastAccess) < time.Second {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.rateLimits.Store(key, time.Now())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func statusString(ok bool) string {
|
func statusString(ok bool) string {
|
||||||
if ok {
|
if ok {
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -153,6 +154,7 @@ func TestReloadHandler_MethodNotAllowed(t *testing.T) {
|
||||||
s := newTestServer()
|
s := newTestServer()
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/reload", nil)
|
req := httptest.NewRequest(http.MethodGet, "/reload", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer test")
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
s.reloadHandler(w, req)
|
s.reloadHandler(w, req)
|
||||||
|
|
@ -346,3 +348,77 @@ func TestStatusString(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVerifyAuth(t *testing.T) {
|
||||||
|
s := &Server{
|
||||||
|
apiKey: "api-key",
|
||||||
|
authToken: "auth-token",
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("Valid X-API-Key", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("X-API-Key", "api-key")
|
||||||
|
if !s.verifyAuth(req) {
|
||||||
|
t.Error("expected true for valid X-API-Key")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Valid Bearer Token", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer auth-token")
|
||||||
|
if !s.verifyAuth(req) {
|
||||||
|
t.Error("expected true for valid Bearer token")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Invalid X-API-Key", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("X-API-Key", "wrong")
|
||||||
|
if s.verifyAuth(req) {
|
||||||
|
t.Error("expected false for invalid X-API-Key")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Invalid Bearer Token", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer wrong")
|
||||||
|
if s.verifyAuth(req) {
|
||||||
|
t.Error("expected false for invalid Bearer token")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Empty Headers When Auth Required", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
if s.verifyAuth(req) {
|
||||||
|
t.Error("expected false for missing auth headers when auth required")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("No Auth Configuration", func(t *testing.T) {
|
||||||
|
sNoAuth := &Server{}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
if !sNoAuth.verifyAuth(req) {
|
||||||
|
t.Error("expected true when no auth is configured")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeID(t *testing.T) {
|
||||||
|
s := &Server{}
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"abc-123_XYZ", "abc-123_XYZ"},
|
||||||
|
{"abc/def..path", "abc_def__path"},
|
||||||
|
{"very" + strings.Repeat("a", 150), "very" + strings.Repeat("a", 124)},
|
||||||
|
{"", ""},
|
||||||
|
{"!@#$%^&*()", "__________"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := s.sanitizeID(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("sanitizeID(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -453,27 +453,27 @@ func (c *OpenClawConfig) GetAgents() []OpenClawAgentEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OpenClawConfig) HasSkills() bool {
|
func (c *OpenClawConfig) HasSkills() bool {
|
||||||
return c.Skills != nil && c.Skills.Entries != nil && len(c.Skills.Entries) > 0
|
return c.Skills != nil && len(c.Skills.Entries) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OpenClawConfig) HasMemory() bool {
|
func (c *OpenClawConfig) HasMemory() bool {
|
||||||
return c.Memory != nil && len(c.Memory) > 0
|
return len(c.Memory) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OpenClawConfig) HasCron() bool {
|
func (c *OpenClawConfig) HasCron() bool {
|
||||||
return c.Cron != nil && len(c.Cron) > 0
|
return len(c.Cron) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OpenClawConfig) HasHooks() bool {
|
func (c *OpenClawConfig) HasHooks() bool {
|
||||||
return c.Hooks != nil && len(c.Hooks) > 0
|
return len(c.Hooks) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OpenClawConfig) HasSession() bool {
|
func (c *OpenClawConfig) HasSession() bool {
|
||||||
return c.Session != nil && len(c.Session) > 0
|
return len(c.Session) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OpenClawConfig) HasAuthProfiles() bool {
|
func (c *OpenClawConfig) HasAuthProfiles() bool {
|
||||||
return c.Auth != nil && c.Auth.Profiles != nil && len(c.Auth.Profiles) > 0
|
return c.Auth != nil && len(c.Auth.Profiles) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OpenClawConfig) ConvertToPicoClaw(sourceHome string) (*PicoClawConfig, []string, error) {
|
func (c *OpenClawConfig) ConvertToPicoClaw(sourceHome string) (*PicoClawConfig, []string, error) {
|
||||||
|
|
@ -510,7 +510,7 @@ func (c *OpenClawConfig) ConvertToPicoClaw(sourceHome string) (*PicoClawConfig,
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cfg.ModelList = append(cfg.ModelList, ModelConfig{
|
cfg.ModelList = append(cfg.ModelList, ModelConfig{
|
||||||
ModelName: fmt.Sprintf("%s", provName),
|
ModelName: provName,
|
||||||
Model: fmt.Sprintf("%s/%s", provName, provName),
|
Model: fmt.Sprintf("%s/%s", provName, provName),
|
||||||
APIKey: provCfg.ApiKey,
|
APIKey: provCfg.ApiKey,
|
||||||
APIBase: provCfg.BaseUrl,
|
APIBase: provCfg.BaseUrl,
|
||||||
|
|
|
||||||
|
|
@ -229,7 +229,7 @@ type bm25CachedEngine struct {
|
||||||
func snapshotToSearchDocs(snap HiddenToolSnapshot) []searchDoc {
|
func snapshotToSearchDocs(snap HiddenToolSnapshot) []searchDoc {
|
||||||
docs := make([]searchDoc, len(snap.Docs))
|
docs := make([]searchDoc, len(snap.Docs))
|
||||||
for i, d := range snap.Docs {
|
for i, d := range snap.Docs {
|
||||||
docs[i] = searchDoc{Name: d.Name, Description: d.Description}
|
docs[i] = searchDoc(d)
|
||||||
}
|
}
|
||||||
return docs
|
return docs
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -337,7 +337,7 @@ func TestProbeLocalModelAvailability_DeduplicatesInflightProbe(t *testing.T) {
|
||||||
results := make(chan bool, workers)
|
results := make(chan bool, workers)
|
||||||
workerStarted := make(chan struct{}, workers)
|
workerStarted := make(chan struct{}, workers)
|
||||||
|
|
||||||
for range workers {
|
for i := 0; i < workers; i++ {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
@ -346,7 +346,7 @@ func TestProbeLocalModelAvailability_DeduplicatesInflightProbe(t *testing.T) {
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
for range workers {
|
for i := 0; i < workers; i++ {
|
||||||
<-workerStarted
|
<-workerStarted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ func (h *Handler) handleGetVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
// resolveSystemVersionInfo prefers the actual picoclaw binary version output,
|
// resolveSystemVersionInfo prefers the actual picoclaw binary version output,
|
||||||
// and falls back to launcher build metadata when command execution fails.
|
// and falls back to launcher build metadata when command execution fails.
|
||||||
func (h *Handler) resolveSystemVersionInfo(ctx context.Context) systemVersionResponse {
|
func (h *Handler) resolveSystemVersionInfo(ctx context.Context) systemVersionResponse {
|
||||||
for range maxVersionResolveAttempts {
|
for i := 0; i < maxVersionResolveAttempts; i++ {
|
||||||
gatewayPID, gatewayAlive := currentGatewayVersionState()
|
gatewayPID, gatewayAlive := currentGatewayVersionState()
|
||||||
if cached, ok := versionInfoCache.get(gatewayPID, gatewayAlive); ok {
|
if cached, ok := versionInfoCache.get(gatewayPID, gatewayAlive); ok {
|
||||||
return cached
|
return cached
|
||||||
|
|
|
||||||
|
|
@ -353,14 +353,8 @@ func main() {
|
||||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||||
|
|
||||||
// Main event loop - wait for signals or config changes
|
// Main event loop - wait for signals or config changes
|
||||||
for {
|
<-sigChan
|
||||||
select {
|
|
||||||
case <-sigChan:
|
|
||||||
logger.Info("Shutting down...")
|
logger.Info("Shutting down...")
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// GUI mode: start system tray
|
// GUI mode: start system tray
|
||||||
runTray()
|
runTray()
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
# 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:
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"jobs": []
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue